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
parent
e6caf308b0
commit
9b1a964c69
@ -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}
|
||||||
Loading…
Reference in new issue