diff --git a/src/uix/eidos/components/words/index.ts b/src/uix/eidos/components/words/index.ts index d7d5aa1ae..cbe72bf4a 100644 --- a/src/uix/eidos/components/words/index.ts +++ b/src/uix/eidos/components/words/index.ts @@ -15,6 +15,7 @@ import Status from './words-status.svelte'; import FindReplace from './words-find-replace.svelte'; import Drawer from './words-drawer.svelte'; import ImageFloatBar from './words-image-float-bar.svelte'; +import BlockHandle from './words-block-handle.svelte'; type WordsNamespace = typeof WordsComponent & { Content: typeof Content; @@ -33,6 +34,7 @@ type WordsNamespace = typeof WordsComponent & { FindReplace: typeof FindReplace; Drawer: typeof Drawer; ImageFloatBar: typeof ImageFloatBar; + BlockHandle: typeof BlockHandle; }; const Words = WordsComponent as WordsNamespace; @@ -52,6 +54,7 @@ Words.Status = Status; Words.FindReplace = FindReplace; Words.Drawer = Drawer; Words.ImageFloatBar = ImageFloatBar; +Words.BlockHandle = BlockHandle; export { Words }; export default Words; diff --git a/src/uix/eidos/components/words/words-block-handle.svelte b/src/uix/eidos/components/words/words-block-handle.svelte new file mode 100644 index 000000000..bd24324c1 --- /dev/null +++ b/src/uix/eidos/components/words/words-block-handle.svelte @@ -0,0 +1,209 @@ + + +{#if position} + +{/if} + +{#if menuOpen && position} +
+ + + + + +
+{/if} diff --git a/src/uix/eidos/components/words/words.css b/src/uix/eidos/components/words/words.css index 41858b007..1df3d8fcc 100644 --- a/src/uix/eidos/components/words/words.css +++ b/src/uix/eidos/components/words/words.css @@ -521,6 +521,88 @@ margin-inline: 0.15rem; } +/* Block handle — Notion-style drag grip rendered to the left of the + hovered block. Hidden by default (`opacity: 0`) and fades in on + hover via opacity transition. Click reveals an action menu + (Move up / Move down / Duplicate / Delete). The button itself is + positioned via inline `top` / `left` set from getBoundingClientRect. */ +[data-words-block-handle] { + position: fixed; + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: 1.5rem; + block-size: 1.5rem; + padding: 0; + border: 0; + border-radius: var(--words-command-radius); + background: transparent; + color: var(--words-status-color); + cursor: grab; + opacity: 0.45; + transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease; + z-index: var(--z-index-popover); +} + +[data-words-block-handle]:hover, +[data-words-block-handle][data-open] { + opacity: 1; + background: var(--words-command-hover-bg); + color: var(--words-command-active-color); +} + +[data-words-block-handle]:active { + cursor: grabbing; +} + +[data-words-block-handle-menu] { + position: fixed; + display: flex; + flex-direction: column; + min-inline-size: 9rem; + padding: 0.25rem; + border-radius: var(--words-command-radius); + background: var(--words-toolbar-bg); + border: var(--words-border-width) solid var(--_words-accent-border); + box-shadow: var(--words-shadow); + z-index: var(--z-index-popover); +} + +[data-words-block-handle-menu] button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.5rem; + border: 0; + border-radius: var(--words-command-radius); + background: transparent; + color: var(--words-command-color); + text-align: start; + font: inherit; + cursor: pointer; +} + +[data-words-block-handle-menu] button:hover:not(:disabled) { + background: color-mix(in srgb, var(--_words-accent-solid) 12%, transparent); + color: var(--_words-accent-text); +} + +[data-words-block-handle-menu] button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +[data-words-block-handle-menu] button[data-tone='risk']:hover:not(:disabled) { + background: color-mix(in srgb, var(--words-threat-text) 15%, transparent); + color: var(--words-threat-text); +} + +[data-words-block-handle-menu-divider] { + block-size: 1px; + margin-block: 0.25rem; + background: var(--words-toolbar-border); +} + [data-words-drawer-section-title] { display: flex; align-items: baseline; diff --git a/src/uix/soma/components/words/engine/commands.ts b/src/uix/soma/components/words/engine/commands.ts index f750135d7..87ac32252 100644 --- a/src/uix/soma/components/words/engine/commands.ts +++ b/src/uix/soma/components/words/engine/commands.ts @@ -29,10 +29,12 @@ import { insertLineBreak, insertParagraph, deleteBlockAt, + duplicateBlockAt, insertImage, insertTable, insertTableColumn, insertTableRow, + moveBlockAt, updateBlockAt, insertText, moveTableCell, @@ -119,6 +121,10 @@ export type WordsCommand = /** Remove a block by index. Used by the image float bar delete * action and any future block-action menus. */ | { type: 'deleteBlock'; blockIndex: number } + /** Swap a block with its neighbor in the given direction. */ + | { type: 'moveBlock'; blockIndex: number; direction: 'up' | 'down' } + /** Insert a deep copy of the block immediately after it. */ + | { type: 'duplicateBlock'; blockIndex: number } | { type: 'insertTableRow'; position?: 'before' | 'after' } | { type: 'insertTableColumn'; position?: 'before' | 'after' } | { type: 'deleteTableRow' } @@ -175,6 +181,10 @@ export function applyWordsCommand( return updateBlockAt(state, command.blockIndex, command.patch); case 'deleteBlock': return deleteBlockAt(state, command.blockIndex); + case 'moveBlock': + return moveBlockAt(state, command.blockIndex, command.direction); + case 'duplicateBlock': + return duplicateBlockAt(state, command.blockIndex); case 'insertTableRow': return insertTableRow(state, { position: command.position }); case 'insertTableColumn': diff --git a/src/uix/soma/components/words/engine/operations.ts b/src/uix/soma/components/words/engine/operations.ts index 5357911f1..67445dc9f 100644 --- a/src/uix/soma/components/words/engine/operations.ts +++ b/src/uix/soma/components/words/engine/operations.ts @@ -807,6 +807,76 @@ export function updateBlockAt( }; } +/** + * Move a block one step in `direction`. No-op when already at the + * boundary. After moving, selection follows the block to its new + * position so the user keeps the visual focus on what they moved. + */ +export function moveBlockAt( + state: WordsEditorState, + blockIndex: number, + direction: 'up' | 'down' +): WordsOperationResult { + const children = state.document.children; + if (blockIndex < 0 || blockIndex >= children.length) return { state, changed: false }; + const target = direction === 'up' ? blockIndex - 1 : blockIndex + 1; + if (target < 0 || target >= children.length) return { state, changed: false }; + const nextChildren = children.slice(); + const tmp = nextChildren[blockIndex]; + nextChildren[blockIndex] = nextChildren[target]; + nextChildren[target] = tmp; + const normalized = normalizeDocument({ + ...state.document, + children: nextChildren + }).document; + const point = pointFromInlineTextOffset(normalized, [target], 0); + const selection = createCollapsedSelection(point.path, point.offset); + return { + state: { + document: normalized, + selection, + activeMarks: getActiveMarksForSelection(normalized, selection) + }, + changed: true + }; +} + +/** + * Insert a deep copy of the block at `blockIndex` immediately after + * it. Selection moves to the duplicate so the user can edit the new + * copy without losing context. + */ +export function duplicateBlockAt( + state: WordsEditorState, + blockIndex: number +): WordsOperationResult { + const block = state.document.children[blockIndex]; + if (!block) return { state, changed: false }; + // Structured clone preserves the deep readonly shape — JSON + // round-trip is portable and avoids importing structuredClone. + const copy = JSON.parse(JSON.stringify(block)) as WordsBlock; + const nextChildren = [ + ...state.document.children.slice(0, blockIndex + 1), + copy, + ...state.document.children.slice(blockIndex + 1) + ]; + const normalized = normalizeDocument({ + ...state.document, + children: nextChildren + }).document; + const newIndex = blockIndex + 1; + const point = pointFromInlineTextOffset(normalized, [newIndex], 0); + const selection = createCollapsedSelection(point.path, point.offset); + return { + state: { + document: normalized, + selection, + activeMarks: getActiveMarksForSelection(normalized, selection) + }, + changed: true + }; +} + /** * Remove the block at `blockIndex`. If the doc would be empty, * leaves an empty paragraph behind so the editor still has something diff --git a/src/uix/soma/components/words/types.ts b/src/uix/soma/components/words/types.ts index 8c35bd0da..617bcca56 100644 --- a/src/uix/soma/components/words/types.ts +++ b/src/uix/soma/components/words/types.ts @@ -8,6 +8,7 @@ import type { PrimitiveSpanAttributes } from '../../types'; import type { + WordsCommand, WordsDocument, WordsExportFormat, WordsHeadingLevel, @@ -138,7 +139,16 @@ export type WordsProviderSnippetProps = { readonly focus: () => void; readonly selectAll: () => void; readonly selectBlock: () => void; + readonly selectAtomicBlock: (blockIndex: number) => void; + readonly clearSelectedBlock: () => void; readonly runCommand: (command: WordsCommandName) => void; + /** + * Apply a command directly without going through the sema runtime + * (no event emit, no semantic side-effects). Use this for discrete + * imperatives invoked by overlays / drag handles / float bars — + * not for sema-tuned text edits. + */ + readonly applyCommand: (command: WordsCommand) => boolean; readonly setCodeLanguage: (language?: string) => boolean; readonly insertLink: (href: string, title?: string) => boolean; readonly unlink: () => boolean; diff --git a/web/routes/uix/components/words/+page.svelte b/web/routes/uix/components/words/+page.svelte index ae050b0b0..6e4216007 100644 --- a/web/routes/uix/components/words/+page.svelte +++ b/web/routes/uix/components/words/+page.svelte @@ -1078,6 +1078,7 @@ {/if} + {#if showStatus} {#if invalid}