You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/components/words/words-block-inserter.svelte

289 lines
10 KiB

<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';
import { wordsDragState } from './words-drag-state.svelte';
let { snippet }: { snippet: ProviderSnippetProps } = $props();
const isDragging = $derived(wordsDragState.sourceIndex !== undefined);
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 — pinned to the TOP edge of block 0.
const first = blocks[0].getBoundingClientRect();
seams.push({ insertIndex: 0, y: first.top, left: first.left, right: first.right });
// Between-blocks seams: pinned to the BOTTOM EDGE of the
// preceding block (not the midpoint of the gap). Per spec:
// "el botón de añadir bloque debe aparecer en el límite
// inferior del área en relación al bloque". The "+" reads as
// "insert AFTER this block" instead of floating in nowhere.
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,
left: Math.min(a.left, b.left),
right: Math.max(a.right, b.right)
});
}
// "After last block" seam — pinned to the BOTTOM edge of the
// last block.
const last = blocks[blocks.length - 1].getBoundingClientRect();
seams.push({
insertIndex: blocks.length,
y: last.bottom,
left: last.left,
right: last.right
});
return seams;
}
/**
* The "+" only shows up when the cursor is OUTSIDE the content box
* (i.e. in the rail column) AND vertically positioned in a gap
* between two blocks. Hovering over a block — either on its text
* or on the rail aligned with the block — surfaces the grip
* instead (block-handle is the actor there). Per user spec:
* "el + solo aparece en la banda entre bloques".
*/
function findSeamForCursor(e: MouseEvent, content: HTMLElement): Seam | null {
const contentRect = content.getBoundingClientRect();
// Cursor inside the content's horizontal extent → user is reading
// text or is in the editor body, not the rail. No "+".
if (e.clientX >= contentRect.left && e.clientX <= contentRect.right) {
return null;
}
// Cursor inside ANY block's vertical band → grip's territory.
// Half-open interval [top, bottom): the EXACT bottom edge belongs
// to the seam below (per spec — "+" anchors to the block's bottom
// edge), so the seam can win when the cursor sits on that pixel.
const blocks = Array.from(
content.querySelectorAll<HTMLElement>(
'[data-words-node="block"][data-words-path]'
)
).filter((b) => !(b.getAttribute('data-words-path') ?? '').includes('.'));
for (const block of blocks) {
const r = block.getBoundingClientRect();
if (e.clientY >= r.top && e.clientY < r.bottom) return null;
}
// Cursor is in the rail AND between blocks — snap to the nearest
// seam. No X-bound check needed; we already know X is in the rail.
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) {
best = s;
bestDelta = delta;
}
}
return best;
}
$effect(() => {
const found = document.querySelector<HTMLElement>('[data-words-content]');
if (!found) return;
// Narrowed once at the top; TS doesn't carry the narrowing into
// the closures below, so we re-bind to a non-null `content`.
const content: HTMLElement = found;
// Track movement on the document, not on `content`, so the "+"
// button (which lives in the left margin OUTSIDE the content
// box) doesn't fire a `mouseleave` on content the moment the
// cursor hits it — that was the source of the flicker loop.
// While the cursor is OVER the inserter overlay (button or
// line) we hold the current seam steady instead of recomputing
// or clearing.
function onmove(e: MouseEvent) {
const target = e.target;
if (target instanceof Element && target.closest('[data-words-block-inserter]')) {
// Cursor is on the inserter itself — keep it visible.
return;
}
const r = content.getBoundingClientRect();
// Hide when clearly outside the content gutter (with a small
// buffer for the "+" button's left margin).
if (
e.clientX < r.left - 48 ||
e.clientX > r.right + 16 ||
e.clientY < r.top - 12 ||
e.clientY > r.bottom + 12
) {
if (seam !== null) seam = null;
return;
}
const next = findSeamForCursor(e, content);
// Same snap target → reuse the existing Seam object so Svelte
// doesn't re-render the overlay for an identical position.
if (next === null && seam === null) return;
if (next !== null && seam !== null && next.insertIndex === seam.insertIndex) {
return;
}
seam = next;
}
document.addEventListener('mousemove', onmove);
// During a drag, the browser fires `dragover` instead of
// `mousemove`. Track both surfaces so the same seam-snapping
// logic doubles as the drop indicator.
function ondragover(e: DragEvent) {
if (wordsDragState.sourceIndex === undefined) return;
// Restrict to dragovers happening over the content area
// (or its margin). Outside that, don't show.
const r = content.getBoundingClientRect();
if (
e.clientX < r.left - 48 ||
e.clientX > r.right + 16 ||
e.clientY < r.top - 12 ||
e.clientY > r.bottom + 12
) {
if (seam !== null) seam = null;
return;
}
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
const next = findSeamForCursor(e, content);
if (next === null && seam === null) return;
if (next !== null && seam !== null && next.insertIndex === seam.insertIndex) {
return;
}
seam = next;
}
function ondrop(e: DragEvent) {
if (wordsDragState.sourceIndex === undefined) return;
if (!seam) return;
e.preventDefault();
const fromIndex = wordsDragState.sourceIndex;
// `seam.insertIndex` is in the pre-removal indexing. If the
// destination is AFTER the source, the source's removal
// shifts everything down by one, so subtract 1.
let toIndex = seam.insertIndex;
if (toIndex > fromIndex) toIndex -= 1;
// Dropping back on yourself or directly adjacent (same slot
// either before or after) is a no-op.
if (toIndex === fromIndex) {
seam = null;
wordsDragState.sourceIndex = undefined;
return;
}
snippet.applyCommand({ type: 'moveBlockTo', fromIndex, toIndex });
seam = null;
wordsDragState.sourceIndex = undefined;
}
document.addEventListener('dragover', ondragover);
document.addEventListener('drop', ondrop);
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 () => {
document.removeEventListener('mousemove', onmove);
document.removeEventListener('dragover', ondragover);
document.removeEventListener('drop', ondrop);
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;
}
/**
* Mirror the drawer's side so the "+" button auto-flips to the
* OPPOSITE gutter (CSS reads `data-side` to swap left:-28 vs
* right:-28). Drawer right (default) → "+" on left margin;
* drawer left → "+" on right margin.
*/
const drawerSide = $derived.by(() => {
// Read reactively each time seam changes — re-evaluating the
// DOM is cheap and the side rarely flips during a session.
if (!seam) return 'right' as const;
const drawer = document.querySelector('[data-words-drawer]');
return drawer?.getAttribute('data-side') === 'left' ? 'left' : 'right';
});
</script>
{#if seam}
<div
data-words-block-inserter
data-mode={isDragging ? 'drop' : 'insert'}
data-side={drawerSide}
style="top: {seam.y}px; left: {seam.left}px; width: {seam.right - seam.left}px;"
role="presentation"
>
{#if !isDragging}
<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>
{/if}
<span data-words-block-inserter-line aria-hidden="true"></span>
</div>
{/if}

Powered by TurnKey Linux.