@ -1,31 +1,21 @@
< script lang = "ts" >
/**
* Block grip — trigger for the block ACTION MENU + drag-handle .
* Block grip — a drag-handle that opens the properties panel .
*
* PATH-UNIFORM (Notion-style inline handle): the grip anchors to ANY block at
* ANY nesting depth. A top-level block gets its grip in the left RAIL; a block
* INSIDE a column gets its grip at the block's own left edge (floating out of
* the rail into the canvas). Every action routes through the engine's
* path-aware commands (`moveBlockAtPath` / `insertBlockAtPath` /
* `deleteBlockAtPath`) addressing the block's full numeric `WordsPath` —
* resolved from its materialized id-path via `findBlockById`.
* PATH-UNIFORM (Notion-style inline handle): the grip anchors to ANY block at ANY
* nesting depth. A top-level block gets its grip in the left RAIL; a block INSIDE a
* column gets its grip at the block's own left edge (floating out of the rail into
* the canvas).
*
* Two behaviours from the same trigger, by the browser's click-vs-drag:
* - CLICK → opens the ACTION MENU (Propiedades · Insertar bloque · Mover
* arriba/abajo · Eliminar). A drag fires no trailing click, so a reorder
* never opens it .
* - DRAG → reorders (HTML5 drag; the canvas `< WordsBlockDrag > ` is the sink).
* Drag is enabled ONLY for top-level blocks — the drop sink is top-level.
* - CLICK → opens the properties PANEL directly. Every per-block action — move /
* insert / delete — now lives in the panel itself (its footer + header), so the
* grip no longer opens an intermediate menu: one click instead of two .
* - DRAG → reorders (HTML5 drag; the canvas `< WordsBlockDrag > ` is the sink). Drag
* is enabled ONLY for top-level blocks — the drop sink is top-level.
*
* TWO open/close loops had to be killed, both seen as the menu flickering:
* 1. EDITOR ↔ menu — opening the menu steals focus from the contenteditable;
* without a marker the editor reads the blur as external, re-grabs focus,
* and the menu closes → reopens. Fix: the trigger and the portaled menu
* BOTH carry `data-words-external-tool`, so the provider's
* `isInsideWordsTool` treats focus landing here as internal.
* 2. GRIP ↔ menu — the menu's `disabled` flags must NOT re-derive from
* `api.html`. The grip captures index/count at placement and is frozen
* while the menu is open. Mirrors Words' stable-`index`.
* The trigger carries `data-words-external-tool` so the provider's
* `isInsideWordsTool` treats focus landing here as internal (no editor churn).
*
* Grip placement: PINNED to the active block (always ⋮). It moves only when the
* active block changes (click / Arrow nav / breadcrumb) — never on hover, which
@ -35,22 +25,9 @@
import type { ActiveDom } from '$adom';
import { findBlockById , type ProviderSnippetProps } from '$soma/components/words';
import { Box } from '$uix/eidos/components/box';
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import {
EllipsisVertical,
Settings2,
LayersPlus,
ArrowBigUp,
ArrowBigDown,
Trash2,
Columns2
} from '$uix/eidos/components/icon';
import { Button } from '$uix/eidos/components/button';
import { EllipsisVertical } from '$uix/eidos/components/icon';
import { WORDS_BLOCK_DRAG_TYPE } from '$uix/eidos/components/words/words-block-drag-type';
import {
blockToInsert,
insertableBlockTypes,
type PalabrasBlockType
} from './palabras-block-types';
let {
api,
@ -67,34 +44,30 @@
onOpen: (id: string) => void;
onClose: () => void;
} = $props();
// `dom` / `panelOpen` / `onClose` are part of the slot's contract (kept so the
// parent's call is unchanged) but the grip no longer needs them — every action
// moved into the panel. Referenced here so they don't read as dead props.
void dom;
void panelOpen;
void onClose;
// Px the inline handle sits to the LEFT of a NESTED block (top-level blocks
// keep their handle flush in the rail at left:0). Must be > the handle's own
// width so it clears the block's text, and the columns `gap` (palabras.css)
// must exceed it so the handle lands cleanly in the inter-column gutter —
// never overlapping the previous column (which would re-anchor on hover).
// Px the inline handle sits to the LEFT of a NESTED block (top-level blocks keep
// their handle flush in the rail at left:0). Must be > the handle's own width so
// it clears the block's text, and the columns `gap` (palabras.css) must exceed it.
const HANDLE_REACH = 30;
// The grip carries its block's full id-path + numeric path + position-in-parent
// (index / count) CAPTURED at placement time (not reactively re-read). Freezing
// these is the churn fix: opening the menu moves focus → provider re-renders →
// re-deriving from `api.html` would re-create the focused item and loop the
// menu open/close. `draggable` is true only for top-level blocks (drag sink is
// top-level). `top`/`left` are host-relative (the slot lives in the rail, whose
// origin coincides with the doc's, so host coords map straight onto it).
// The grip carries its block's full id-path + numeric path + position-in-parent,
// captured at placement. `draggable` is true only for top-level blocks (drag sink
// is top-level). `top`/`left` are host-relative (the slot lives in the rail).
type Grip = {
top: number;
left: number;
id: string;
path: readonly number[];
index: number;
count: number;
draggable: boolean;
};
let grip = $state< Grip | null > (null);
let menuOpen = $state(false);
// LOCAL reads on elements we own — left inline per ADom doctrine.
/** Full materialized id-path of a block element: the `/`-joined chain of every
* `data-words-id` ancestor (root → block). Matches `findBlockById`'s addressing. */
function idPathOf(target: HTMLElement, host: HTMLElement): string {
@ -124,28 +97,17 @@
}
return null;
}
/** Nearest preceding/following SIBLING that is a block (has `data-words-id`). */
function blockSibling(el: HTMLElement, dir: 'prev' | 'next'): HTMLElement | null {
let s = dir === 'prev' ? el.previousElementSibling : el.nextElementSibling;
while (s && !(s instanceof HTMLElement && s.hasAttribute('data-words-id'))) {
s = dir === 'prev' ? s.previousElementSibling : s.nextElementSibling;
}
return s instanceof HTMLElement ? s : null;
}
/** Top-level index of a block (or of its top-level ancestor) — `path` fallback. */
function topIndexOf(block: HTMLElement): number {
let el: HTMLElement = block;
while (el.parentElement & & el.parentElement !== content) el = el.parentElement;
return topLevelBlocks().indexOf(el);
}
// Build the grip for a block: position (host-relative), full id-path, numeric
// path (from the model via `findBlockById`), and its index/count among its
// DIRECT block siblings (the parent container's blocks).
// Build the grip for a block: position (host-relative), full id-path, numeric path,
// and whether it's draggable (top-level only).
function gripFor(block: HTMLElement, h: HTMLElement): Grip {
// A LIST is gripped as ONE unit — never an individual item. Clicking inside a
// list activates the item (its caret lands there), but the grip + its menu
// must target the whole list block (and sit at the list's left edge, not
// indented inside it). Climb from a list item to its list block.
// A LIST is gripped as ONE unit — never an individual item. Climb from a list
// item to its list block (the grip targets the whole list, at its left edge).
if (block.getAttribute('data-words-node') === 'list-item') {
const listBlock = block.closest('[data-words-block]');
if (listBlock instanceof HTMLElement) block = listBlock;
@ -155,263 +117,68 @@
const id = idPathOf(block, h);
const loc = findBlockById(api.document, id);
const path = loc?.path ?? [topIndexOf(block)];
const parentEl = block.parentElement;
const sibs = parentEl
? Array.from(parentEl.children).filter(
(el): el is HTMLElement => el instanceof HTMLElement & & el.hasAttribute('data-words-id')
)
: [block];
const isTop = path.length < = 1;
return {
top: br.top - hr.top + br.height / 2,
// Top-level → flush in the rail. Nested → just left of the block, the
// handle floating out of the rail into the canvas (rail has no overflow
// clip, so it spills over the columns).
// Top-level → flush in the rail. Nested → just left of the block, floating
// out of the rail into the canvas.
left: isTop ? 0 : Math.round(br.left - hr.left - HANDLE_REACH),
id,
path,
index: Math.max(0, sibs.indexOf(block)),
count: sibs.length,
draggable: isTop
};
}
// Index + count of the grip's block among its siblings — read from the FROZEN
// grip, NOT from `api.html`. Drives the move up/down disabled flags. The grip
// stays put while the menu is open, so these are stable for the menu's whole
// lifetime.
const blockIndex = $derived(grip?.index ?? -1);
const blockCount = $derived(grip?.count ?? 0);
// Whether the active block lives inside a top-level COLUMN — gates the
// "Eliminar columna" action (the column has no button of its own once it holds
// blocks, so deleting it must live in the block's grip menu).
const inColumn = $derived.by(
() =>
!!grip & & grip.path.length >= 3 & & api.document.children[grip.path[0]]?.type === 'columns'
);
// PINNED to the active block — the grip shows ⋮ and moves ONLY when the active
// block changes (a click → WordsActivate, Arrow nav, or a breadcrumb crumb). It
// does NOT follow the cursor on hover — that was erratic and made it impossible
// to grip the right level. FROZEN while the menu is open.
// does NOT follow the cursor on hover.
$effect(() => {
const h = host();
const activeId = api.activeBlockId;
void api.html;
const mo = menuOpen;
if (!h || mo) return;
if (!h) return;
const activeEl = activeId ? findElByIdPath(activeId) : null;
untrack(() => {
grip = activeEl ? gripFor(activeEl, h) : null;
});
});
// Re-pressing the grip WHILE THE MENU IS OPEN must close it cleanly. The menu's
// Dismissal layer treats any pointerdown OUTSIDE the menu content as an
// outside-dismiss — and the grip (the trigger) is NOT inside the menu, so it
// counts as "outside": close on pointerdown, then the trailing click re-opens
// = flicker. Stopping propagation only while open keeps the pointerdown from
// reaching the Dismissal, so the click is the single source of the toggle.
function onTriggerPointerDown(e: PointerEvent) {
if (menuOpen) e.stopPropagation();
}
// HTML5 drag — top-level blocks ONLY (the drop sink reorders top-level). Sets
// the canonical MIME + effectAllowed; no preventDefault, no state writes.
function onDragStart(e: DragEvent) {
if (!grip || !grip.draggable || !e.dataTransfer) return;
e.dataTransfer.setData(WORDS_BLOCK_DRAG_TYPE, String(grip.path[0]));
e.dataTransfer.effectAllowed = 'move';
}
// ── Menu actions (all path-aware) ─────────────────────────────────────────
// CLICK → open the properties panel on this block.
function openProperties() {
if (!grip) return;
api.setActiveBlock(grip.id);
onOpen(grip.id);
}
function doMoveUp() {
if (!grip || blockIndex < = 0) return;
api.setActiveBlock(grip.id);
api.applyCommand({ type : 'moveBlockAtPath' , blockPath : grip.path , direction : 'up' } );
}
function doMoveDown() {
if (!grip || blockIndex < 0 | | blockIndex > = blockCount - 1) return;
api.setActiveBlock(grip.id);
api.applyCommand({ type : 'moveBlockAtPath' , blockPath : grip.path , direction : 'down' } );
}
// Sibling slot right after the grip's block, in the SAME container.
function insertSiblingPath(g: Grip): readonly number[] {
return [...g.path.slice(0, -1), g.path[g.path.length - 1] + 1];
}
// Insertable block types (registry-derived, static) for the "Insertar bloque"
// submenu grid. `blockToInsert` builds the raw block; the engine normalises it.
const blockTypes = insertableBlockTypes();
// Columns are a TOP-LEVEL layout primitive — never offered when the gripped
// block is NESTED (inside a column / cell / callout). Mirrors the per-column
// "+" filter and the engine's no-columns-in-columns guard.
const insertTypes = $derived(
grip & & grip.path.length > 1 ? blockTypes.filter((t) => t.id !== 'columns') : blockTypes
);
function doInsertType(bt: PalabrasBlockType) {
if (!grip) return;
const blockPath = insertSiblingPath(grip);
// Assign the id UP FRONT so we can re-point the active block (and the grip)
// onto the NEW block the instant it's inserted — the engine preserves a
// provided id. For text blocks the engine also carries a caret into the new
// block (editable immediately); for image / divider / table / callout there
// is no caret, so this explicit activate is what makes the inspector follow.
// Image inserts a PLACEHOLDER (`{ type : 'image' , src : '' } `); its URL / alt
// are filled from the properties panel afterwards — no file picker on insert.
const id = crypto.randomUUID();
api.applyCommand({
type: 'insertBlockAtPath',
blockPath,
block: { ... blockToInsert ( bt ), id }
});
// New block's materialized id-path: the gripped block's parent prefix + new id
// (it's inserted as a sibling, so they share the parent container).
const parentSegs = grip.id.split('/').slice(0, -1);
api.setActiveBlock([...parentSegs, id].join('/'));
}
// On close, land the caret in the editor — for an inserted text block the engine
// carried the model selection INTO it, so `restoreCaret` makes it editable
// without a click. `preventDefault` stops the menu pulling focus back to the
// grip trigger (which would leave the new block unfocused). Mirrors the column
// inserter's close handler.
function onMenuCloseAutoFocus(e: Event) {
e.preventDefault();
dom.requestFrame(() => api.restoreCaret(), content);
}
function doDelete() {
if (!grip) return;
const h = host();
const el = findElByIdPath(grip.id);
// Re-point the active block to a surviving SIBLING (read from the pre-delete
// DOM; the survivor's id-path is stable across the re-render). No sibling →
// clear (an emptied column falls back to its own affordance).
let survivorId: string | null = null;
if (el && h) {
const sib = blockSibling(el, 'prev') ?? blockSibling(el, 'next');
survivorId = sib ? idPathOf(sib, h) : null;
}
api.applyCommand({ type : 'deleteBlockAtPath' , blockPath : grip.path } );
api.setActiveBlock(survivorId);
onClose();
}
// Select the whole COLUMNS block (the container) — the only reliable way to grip
// it once its columns hold blocks (clicking inside always lands on the inner
// block). Its id is the FIRST segment of the active block's materialized path.
function doSelectColumns() {
if (!grip || grip.path.length < 3 ) return ;
const columnsId = grip.id.split('/')[0];
if (columnsId) api.setActiveBlock(columnsId);
}
// Delete the WHOLE column the active block sits in (only shown when `inColumn`).
function doDeleteColumn() {
if (!grip || grip.path.length < 3 ) return ;
api.applyCommand({ type : 'deleteColumn' , columnsIdx : grip.path [ 0 ], colIdx : grip.path [ 1 ] } );
// The column's blocks are gone (and the columns block may have unwrapped) —
// clear the active block; the user re-activates by clicking a survivor.
api.setActiveBlock(null);
onClose();
// HTML5 drag — top-level blocks ONLY. Sets the canonical MIME + effectAllowed.
function onDragStart(e: DragEvent) {
if (!grip || !grip.draggable || !e.dataTransfer) return;
e.dataTransfer.setData(WORDS_BLOCK_DRAG_TYPE, String(grip.path[0]));
e.dataTransfer.effectAllowed = 'move';
}
< / script >
{ #if grip }
<!-- Positioned slot: owns the gutter/inline placement. `top` + `left` are
host-relative inline (the slot lives in the rail, whose origin coincides
with the doc's). -->
<!-- Positioned slot: owns the gutter/inline placement (host - relative inline). -->
< Box data-palabras-handle-slot style = "top: { grip . top } px; left: { grip . left } px" >
< DropdownMenu bind:open = { menuOpen } >
<!-- Trigger = the framework <Button> (ghost/xs/iconOnly), a drag source
(draggable + ondragstart) for top-level blocks. `data-words-external-tool`
marks it as part of the editor's focus scope so opening the menu doesn't loop. -->
< DropdownMenu.Trigger
variant="ghost"
size="xs"
iconOnly
rounded="full"
data-palabras-handle
data-nested={ grip . path . length > 1 ? '' : undefined }
data-words-external-tool
aria-label="Acciones del bloque"
draggable={ grip . draggable }
onpointerdown={ onTriggerPointerDown }
ondragstart={ onDragStart }
>
{ # snippet icon ()} < EllipsisVertical /> { /snippet }
Acciones del bloque
< / DropdownMenu.Trigger >
<!-- Portal the menu to <body>: the grip slot has `transform: translateY( - 50%)`,
which makes it the containing block for `position: fixed` descendants.
Portaling escapes the transform so floating-ui positioning + collision
are viewport-correct. -->
< DropdownMenu.Portal >
<!-- `preventScroll={false}`: a block - action menu must NOT lock page scroll
(its ScrollLock toggles the scrollbar = the visible flicker/jump).
`data-words-external-tool`: focus-scope marker on the portaled menu. -->
< DropdownMenu.Content
side="bottom"
align="start"
preventScroll={ false }
onCloseAutoFocus={ onMenuCloseAutoFocus }
data-palabras-handle-menu
data-words-external-tool
>
< DropdownMenu.Item onSelect = { openProperties } >
< Settings2 / >
< span > Propiedades< / span >
< / DropdownMenu.Item >
< DropdownMenu.Separator / >
< DropdownMenu.Sub >
< DropdownMenu.SubTrigger >
< LayersPlus / >
< span > Insertar bloque< / span >
< / DropdownMenu.SubTrigger >
< DropdownMenu.SubContent
data-palabras-handle-menu
data-words-external-tool
data-palabras-insert-grid
>
{ #each insertTypes as bt ( bt . id )}
{ @const Icon = bt . icon }
< DropdownMenu.Item onSelect = {() => doInsertType ( bt )} >
< Icon / >
< span > { bt . label } </ span >
< / DropdownMenu.Item >
{ /each }
< / DropdownMenu.SubContent >
< / DropdownMenu.Sub >
< DropdownMenu.Item onSelect = { doMoveUp } disabled= { blockIndex <= 0 } >
< ArrowBigUp / >
< span > Mover arriba< / span >
< / DropdownMenu.Item >
< DropdownMenu.Item
onSelect={ doMoveDown }
disabled={ blockIndex < 0 || blockIndex >= blockCount - 1 }
>
< ArrowBigDown / >
< span > Mover abajo< / span >
< / DropdownMenu.Item >
< DropdownMenu.Separator / >
< DropdownMenu.Item onSelect = { doDelete } >
< Trash2 / >
< span > Eliminar bloque< / span >
< / DropdownMenu.Item >
{ #if inColumn }
< DropdownMenu.Separator / >
< DropdownMenu.Item onSelect = { doSelectColumns } >
< Columns2 / >
< span > Seleccionar columnas< / span >
< / DropdownMenu.Item >
< DropdownMenu.Item onSelect = { doDeleteColumn } >
< Trash2 / >
< span > Eliminar columna< / span >
< / DropdownMenu.Item >
{ /if }
< / DropdownMenu.Content >
< / DropdownMenu.Portal >
< / DropdownMenu >
<!-- The grip = a draggable framework <Button>. CLICK opens the properties panel
directly (its footer + header hold every per-block action — no menu). DRAG
(top-level only) reorders. `data-words-external-tool` keeps focus landing
here inside the editor's focus scope. -->
< Button
variant="ghost"
size="xs"
iconOnly
rounded="full"
data-palabras-handle
data-nested={ grip . path . length > 1 ? '' : undefined }
data-words-external-tool
aria-label="Propiedades del bloque"
title="Propiedades del bloque"
draggable={ grip . draggable }
ondragstart={ onDragStart }
onclick={ openProperties }
>
{ # snippet icon ()} < EllipsisVertical /> { /snippet }
< / Button >
< / Box >
{ /if }