From 66225404e1b6b7226d1438b064b618dc20b834df Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 27 May 2026 22:26:42 +0200 Subject: [PATCH] feat(words): drag handle + block action menu per block (POLISH-2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notion-style :: grip rendered to the left of the hovered block via a single overlay component (`Words.BlockHandle`). Click reveals a small popover with Move up / Move down / Duplicate / Delete. Each fires via `snippet.applyCommand` so the discrete imperatives bypass the sema runtime (no extra event noise on top of what the operation already implies for downstream consumers). Backed by three new generic operations on the engine: - `moveBlockAt(state, blockIndex, direction)` — swap with neighbour. - `duplicateBlockAt(state, blockIndex)` — JSON-clone insert-after. - `deleteBlockAt(state, blockIndex)` — already existed; now wired to the menu's Delete entry. The three operations are exposed as `moveBlock` / `duplicateBlock` / `deleteBlock` commands in `WordsCommand` so any future block-toolbar can reuse them without per-feature engine plumbing. Also: add `applyCommand` / `selectAtomicBlock` / `clearSelectedBlock` to `WordsProviderSnippetProps` — they were emitted by the provider but missing from the type. Float bar + block handle both consume `snippet.applyCommand`. Verified in browser: hover over a block renders the grip (24×24, opacity 0.45 → 1 on hover, z-index popover); click opens menu with all 4 actions correctly labelled; Duplicate on the heading at index 0 produced a new heading at index 1 and pushed the rest down. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/uix/eidos/components/words/index.ts | 3 + .../words/words-block-handle.svelte | 209 ++++++++++++++++++ src/uix/eidos/components/words/words.css | 82 +++++++ .../soma/components/words/engine/commands.ts | 10 + .../components/words/engine/operations.ts | 70 ++++++ src/uix/soma/components/words/types.ts | 10 + web/routes/uix/components/words/+page.svelte | 1 + 7 files changed, 385 insertions(+) create mode 100644 src/uix/eidos/components/words/words-block-handle.svelte 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}