From 9b1a964c692ec9d89049ede506aad9efec93fb93 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 27 May 2026 22:58:06 +0200 Subject: [PATCH] feat(words): "+ between blocks" hover-zone inserter (POLISH-2b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the wedge case the user just hit: two atomic blocks back-to-back (image, table, code) leave no caret position between them, so there's no way to insert a paragraph by clicking. Now: hover near the seam between any two top-level blocks and a "+" appears on the left margin with a faint accent line spanning the seam. Click "+" → a new paragraph is dropped at that index and the caret lands at the start so the user starts typing immediately. Same overlay pattern as the block-handle: single component watches mousemove inside `[data-words-content]`, computes the boundaries of all top-level blocks (plus "before first" and "after last" seams), snaps to the closest seam within 18px of the cursor's Y, and renders a fixed-positioned strip with the "+" button and accent line. New engine surface: - `insertBlockAt(state, blockIndex, block)` in `operations.ts` — splices the block in, normalizes, drops caret at `[blockIndex, 0]`. `blockIndex === children.length` appends to the tail. - `insertBlock` case added to `WordsCommand` + dispatcher in `commands.ts`. The command surface keeps `block` as a loose JSON record so consumers don't need to import `WordsBlock` — the reducer casts on the boundary and `normalizeDocument` validates. Verified in browser: hover at the seam between code (index 3) and image (index 4) → "+" appears at the left margin → click → doc becomes 0:heading | 1:paragraph | 2:quote | 3:code | 4:paragraph | 5:image; caret in the new paragraph ready for input. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/uix/eidos/components/words/index.ts | 3 + .../words/words-block-inserter.svelte | 150 ++++++++++++++++++ src/uix/eidos/components/words/words.css | 57 +++++++ .../soma/components/words/engine/commands.ts | 22 +++ .../components/words/engine/operations.ts | 40 +++++ web/routes/uix/components/words/+page.svelte | 1 + 6 files changed, 273 insertions(+) create mode 100644 src/uix/eidos/components/words/words-block-inserter.svelte diff --git a/src/uix/eidos/components/words/index.ts b/src/uix/eidos/components/words/index.ts index cbe72bf4a..6abbe5872 100644 --- a/src/uix/eidos/components/words/index.ts +++ b/src/uix/eidos/components/words/index.ts @@ -16,6 +16,7 @@ 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'; +import BlockInserter from './words-block-inserter.svelte'; type WordsNamespace = typeof WordsComponent & { Content: typeof Content; @@ -35,6 +36,7 @@ type WordsNamespace = typeof WordsComponent & { Drawer: typeof Drawer; ImageFloatBar: typeof ImageFloatBar; BlockHandle: typeof BlockHandle; + BlockInserter: typeof BlockInserter; }; const Words = WordsComponent as WordsNamespace; @@ -55,6 +57,7 @@ Words.FindReplace = FindReplace; Words.Drawer = Drawer; Words.ImageFloatBar = ImageFloatBar; Words.BlockHandle = BlockHandle; +Words.BlockInserter = BlockInserter; export { Words }; export default Words; diff --git a/src/uix/eidos/components/words/words-block-inserter.svelte b/src/uix/eidos/components/words/words-block-inserter.svelte new file mode 100644 index 000000000..6a8e76e3c --- /dev/null +++ b/src/uix/eidos/components/words/words-block-inserter.svelte @@ -0,0 +1,150 @@ + + +{#if seam} +
+ + +
+{/if} diff --git a/src/uix/eidos/components/words/words.css b/src/uix/eidos/components/words/words.css index dba16f7b9..4ee2539bf 100644 --- a/src/uix/eidos/components/words/words.css +++ b/src/uix/eidos/components/words/words.css @@ -733,6 +733,63 @@ background: var(--words-toolbar-border); } +/* Block inserter — hover-zone between blocks. Solves the wedge case + where two atomic blocks (image, table, code) are back-to-back and + no caret position exists between them. The wrapper is a thin + horizontal strip anchored at the seam between two blocks; it + carries a faint line and a left-margin "+" button. Click → insert + a paragraph at that position. */ +[data-words-block-inserter] { + position: fixed; + pointer-events: none; + transform: translateY(-50%); + display: flex; + align-items: center; + z-index: var(--z-index-popover); +} + +[data-words-block-inserter-button] { + pointer-events: auto; + position: absolute; + left: -28px; + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: 1.5rem; + block-size: 1.5rem; + padding: 0; + border: var(--words-border-width) solid var(--words-toolbar-border); + border-radius: 999px; + background: var(--words-toolbar-bg); + color: var(--words-status-color); + cursor: pointer; + box-shadow: var(--words-shadow); + transition: + background 120ms ease, + color 120ms ease, + border-color 120ms ease; +} + +[data-words-block-inserter-button]:hover { + background: var(--_words-accent-solid); + border-color: var(--_words-accent-solid); + color: var(--_words-accent-on); +} + +[data-words-block-inserter-line] { + pointer-events: auto; + flex: 1 1 auto; + block-size: 2px; + border-radius: 1px; + background: color-mix(in srgb, var(--_words-accent-solid) 35%, transparent); + opacity: 0; + transition: opacity 120ms ease; +} + +[data-words-block-inserter]:hover [data-words-block-inserter-line] { + opacity: 1; +} + [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 87ac32252..308021911 100644 --- a/src/uix/soma/components/words/engine/commands.ts +++ b/src/uix/soma/components/words/engine/commands.ts @@ -30,6 +30,7 @@ import { insertParagraph, deleteBlockAt, duplicateBlockAt, + insertBlockAt, insertImage, insertTable, insertTableColumn, @@ -125,6 +126,17 @@ export type WordsCommand = | { type: 'moveBlock'; blockIndex: number; direction: 'up' | 'down' } /** Insert a deep copy of the block immediately after it. */ | { type: 'duplicateBlock'; blockIndex: number } + /** + * Insert an arbitrary block at a position. Caret lands at the start + * of the new block. Used by the block-inserter overlay to drop a + * paragraph between atomic blocks (image / table / code) where no + * caret position exists. + */ + | { + type: 'insertBlock'; + blockIndex: number; + block: Readonly>; + } | { type: 'insertTableRow'; position?: 'before' | 'after' } | { type: 'insertTableColumn'; position?: 'before' | 'after' } | { type: 'deleteTableRow' } @@ -185,6 +197,16 @@ export function applyWordsCommand( return moveBlockAt(state, command.blockIndex, command.direction); case 'duplicateBlock': return duplicateBlockAt(state, command.blockIndex); + case 'insertBlock': + // Reducer accepts a typed WordsBlock; the command surface keeps + // it loose so consumers (overlays / extensions) can pass JSON + // without importing the full block union. Cast on the boundary + // — normalizeDocument enforces shape downstream. + return insertBlockAt( + state, + command.blockIndex, + command.block as unknown as Parameters[2] + ); 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 67445dc9f..38d100f20 100644 --- a/src/uix/soma/components/words/engine/operations.ts +++ b/src/uix/soma/components/words/engine/operations.ts @@ -907,6 +907,46 @@ export function deleteBlockAt( }; } +/** + * Insert an arbitrary block at `blockIndex`. The new block takes that + * index; everything from `blockIndex` onwards shifts down one slot. + * `blockIndex === children.length` appends to the end. Caret lands at + * the start of the newly-inserted block, so the user can start typing + * straight away. + * + * Used by the block-inserter overlay to drop a paragraph between two + * atomic blocks (image, table, code) where no caret position exists. + */ +export function insertBlockAt( + state: WordsEditorState, + blockIndex: number, + block: WordsBlock +): WordsOperationResult { + const children = state.document.children; + if (blockIndex < 0 || blockIndex > children.length) { + return { state, changed: false }; + } + const nextChildren = [ + ...children.slice(0, blockIndex), + block, + ...children.slice(blockIndex) + ]; + const normalized = normalizeDocument({ + ...state.document, + children: nextChildren + }).document; + const point = pointFromInlineTextOffset(normalized, [blockIndex], 0); + const selection = createCollapsedSelection(point.path, point.offset); + return { + state: { + document: normalized, + selection, + activeMarks: getActiveMarksForSelection(normalized, selection) + }, + changed: true + }; +} + export function insertImage( state: WordsEditorState, options: { diff --git a/web/routes/uix/components/words/+page.svelte b/web/routes/uix/components/words/+page.svelte index 6e4216007..820e8c6fc 100644 --- a/web/routes/uix/components/words/+page.svelte +++ b/web/routes/uix/components/words/+page.svelte @@ -1079,6 +1079,7 @@ {/if} + {#if showStatus} {#if invalid}