feat(words): "+ between blocks" hover-zone inserter (POLISH-2b)

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) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent e6caf308b0
commit 9b1a964c69

@ -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;

@ -0,0 +1,150 @@
<script lang="ts">
/**
* Words.BlockInserter — "+" hover-zone between blocks (Notion-style).
*
* Solves the wedge problem: with atomic blocks (image, table, code)
* back-to-back there's no caret position between them, so the user
* can't insert a paragraph by clicking. This overlay watches mouse
* Y inside `[data-words-content]`, finds the nearest seam between
* two adjacent top-level blocks (plus the seams before the first
* and after the last), and renders a horizontal hover-line with a
* floating "+" on the left margin.
*
* Click → `snippet.applyCommand({ type: 'insertBlock', blockIndex,
* block: createParagraph() })`. Caret lands at the start of the
* new paragraph so the user types straight away.
*
* Single overlay reused across all seams — same pattern as the
* block-handle and image-float-bar. No sema (these are discrete
* imperatives, not perceptual events).
*/
import { Plus } from '$uix/eidos/components/icon';
import type { ProviderSnippetProps } from '$soma/components/words';
let { snippet }: { snippet: ProviderSnippetProps } = $props();
type Seam = {
readonly insertIndex: number;
readonly y: number;
readonly left: number;
readonly right: number;
};
// Seam under the cursor (snapped to the closest one within
// `SEAM_RANGE_PX`). null while the cursor is too far from any seam.
let seam = $state<Seam | null>(null);
const SEAM_RANGE_PX = 18;
function listBlockBoundaries(content: HTMLElement): Seam[] {
// Top-level blocks only — same predicate as the block-handle.
const blocks = Array.from(
content.querySelectorAll<HTMLElement>(
'[data-words-node="block"][data-words-path]'
)
).filter((el) => !(el.getAttribute('data-words-path') ?? '').includes('.'));
if (!blocks.length) return [];
const seams: Seam[] = [];
// "Before first block" seam.
const first = blocks[0].getBoundingClientRect();
seams.push({ insertIndex: 0, y: first.top, left: first.left, right: first.right });
// Between-blocks seams: anchor at midpoint of the visual gap.
for (let i = 0; i < blocks.length - 1; i++) {
const a = blocks[i].getBoundingClientRect();
const b = blocks[i + 1].getBoundingClientRect();
seams.push({
insertIndex: i + 1,
y: (a.bottom + b.top) / 2,
left: Math.min(a.left, b.left),
right: Math.max(a.right, b.right)
});
}
// "After last block" seam.
const last = blocks[blocks.length - 1].getBoundingClientRect();
seams.push({
insertIndex: blocks.length,
y: last.bottom,
left: last.left,
right: last.right
});
return seams;
}
function findSeamForCursor(e: MouseEvent, content: HTMLElement): Seam | null {
const seams = listBlockBoundaries(content);
let best: Seam | null = null;
let bestDelta = SEAM_RANGE_PX;
for (const s of seams) {
const delta = Math.abs(s.y - e.clientY);
if (delta < bestDelta && e.clientX >= s.left - 32 && e.clientX <= s.right + 32) {
best = s;
bestDelta = delta;
}
}
return best;
}
$effect(() => {
const content = document.querySelector<HTMLElement>('[data-words-content]');
if (!content) return;
function onmove(e: MouseEvent) {
seam = findSeamForCursor(e, content);
}
function onleave() {
seam = null;
}
content.addEventListener('mousemove', onmove);
content.addEventListener('mouseleave', onleave);
const win = content.ownerDocument.defaultView;
const onscroll = () => {
// Cursor stays put on scroll; recompute under the LAST known
// pointer would require tracking the cursor. Simplest: hide
// on scroll and let the next mousemove re-snap.
seam = null;
};
win?.addEventListener('scroll', onscroll, { passive: true });
win?.addEventListener('resize', onscroll, { passive: true });
return () => {
content.removeEventListener('mousemove', onmove);
content.removeEventListener('mouseleave', onleave);
win?.removeEventListener('scroll', onscroll);
win?.removeEventListener('resize', onscroll);
};
});
function insertHere() {
if (!seam) return;
const blockIndex = seam.insertIndex;
snippet.applyCommand({
type: 'insertBlock',
blockIndex,
// Plain JSON paragraph — normalizeDocument fills in defaults
// (`children`, marks) and validates the shape.
block: { type: 'paragraph', children: [{ type: 'text', text: '' }] }
});
seam = null;
}
</script>
{#if seam}
<div
data-words-block-inserter
style="top: {seam.y}px; left: {seam.left}px; width: {seam.right - seam.left}px;"
role="presentation"
>
<button
type="button"
data-words-block-inserter-button
title="Insert paragraph here"
aria-label="Insert paragraph here"
onclick={insertHere}
onmousedown={(e) => e.preventDefault()}
>
<Plus size="xs" decorative />
</button>
<span data-words-block-inserter-line aria-hidden="true"></span>
</div>
{/if}

@ -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;

@ -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<Record<string, unknown>>;
}
| { 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<typeof insertBlockAt>[2]
);
case 'insertTableRow':
return insertTableRow(state, { position: command.position });
case 'insertTableColumn':

@ -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: {

@ -1079,6 +1079,7 @@
{/if}
<Words.ImageFloatBar snippet={snippetProps} />
<Words.BlockHandle snippet={snippetProps} />
<Words.BlockInserter snippet={snippetProps} />
{#if showStatus}
<Words.Status>
{#if invalid}

Loading…
Cancel
Save

Powered by TurnKey Linux.