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.
289 lines
10 KiB
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}
|