palabras(F4.3b): resize de columna por arrastre (v1)

Cierra la UI de F4 (las tres piezas elegidas por el usuario: listas · merge/split
· count/TOC · resize). Alcance v1 acordado: solo COLUMNAS (sin filas ni snap).

palabras-table-resize.svelte (NUEVO): overlay de tiradores en el borde superior
de cada tabla top-level (uno por borde interno de columna), materializados solo
al pasar el puntero por la tabla (mirror de palabras-column-inserter: medición
frame-relative, remeasure en api.html + scroll/resize). Arrastrar un tirador
redimensiona la columna a su IZQUIERDA:
- feedback EN VIVO por inline `width` en la celda de la 1ª fila durante el drag
  (el navegador dimensiona la columna por la celda más ancha),
- commit a `cell.width` en UN paso de historia al soltar (nada durante el drag),
- doble-clic = auto (limpia el width).

`cell.width` es una LONGITUD CSS en string (el campo `length` del panel commitea
p.ej. "314px") — un número crudo renderiza `width:314` inválido; el commit usa
`${w}px`. Restringido a tablas TOP-LEVEL (path del commit `[table,0,col]`).
Filas + snap a fracciones (1/2·1/3·2/3) diferidos a v1.1. Langs `resize-column`
diferido (entanglement; ts() por fallback).

Verificado Playwright headless: hover tabla → 2 tiradores (3 columnas); arrastrar
+80px → col0 234→314 (las otras se ajustan 183/196), commit width:314px; doble-
clic → auto restaurado (234/221/238); 0 errores consola. Screenshot MIRADO
(tirador de acento en el borde hover). check 0 errores palabras.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 3 months ago
parent a97d8db740
commit 86a3386fcc

@ -422,6 +422,37 @@
padding-inline-start: var(--space-8);
}
/* Column resize (F4.3b) — boundary handles over a table's top edge, shown while
the pointer is over the table. The line is faint at rest, accent on hover /
drag; the wide hit-area carries the col-resize cursor. */
[data-palabras-col-resize] {
position: absolute;
z-index: 4;
inline-size: 11px;
translate: -50% 0;
display: none;
cursor: col-resize;
touch-action: none;
}
[data-palabras-col-resize][data-visible] {
display: block;
}
[data-palabras-col-resize]::before {
content: '';
position: absolute;
inset-block: 0;
inset-inline-start: 50%;
inline-size: 2px;
translate: -50% 0;
border-radius: var(--radius-full);
background: var(--color-border-default);
}
[data-palabras-col-resize]:hover::before,
[data-palabras-col-resize][data-dragging]::before {
inline-size: 3px;
background: var(--color-primary-border);
}
/* ═══ 2 · Inspector panel — the approved mockup, in theme tokens ════════════ */
/* UI sans across the panel (the Accordion content archetype would leak the

@ -0,0 +1,212 @@
<script lang="ts">
/**
* Column resize (F4.3b v1) — spatial handles on a table's column boundaries.
*
* On pointer over a TOP-LEVEL table, thin vertical handles materialize at each
* internal column boundary, spanning the table's height. Dragging one resizes
* the column to its LEFT: LIVE via an inline width on the first-row cell during
* the drag (the browser sizes the whole column from the widest declared cell),
* committed to the model's `cell.width` in ONE history entry on release. A
* double-click clears the width (auto). Frame-relative overlay (mirrors
* palabras-column-inserter) so it never makes the canvas a containing block.
*
* Restricted to TOP-LEVEL tables (the commit path is `[table, 0, col]`). Row
* height + fraction snap are deferred (v1.1).
*/
import { untrack } from 'svelte';
import type { ActiveDom } from '$adom';
import type { ProviderSnippetProps } from '$soma/components/palabras';
import { ActiveEidos } from '$uix/eidos';
let {
api,
content,
dom
}: {
api: ProviderSnippetProps;
content: HTMLElement;
dom: ActiveDom;
} = $props();
const eidos = ActiveEidos.require();
const ts = (ref: string) => eidos.langs.ts(ref);
const MIN_COL = 48; // px — a column never drags narrower than this.
type Handle = {
key: string;
left: number; // frame-relative
top: number;
height: number;
tableIdx: number;
colIdx: number; // the column to the LEFT of this boundary
};
let handles = $state<Handle[]>([]);
// `${tableIdx}` of the table under the cursor — handles show only for it.
let hoveredKey = $state<string | null>(null);
let dragKey = $state<string | null>(null);
function frame(): HTMLElement | null {
return (content?.closest('[data-palabras]') as HTMLElement | null) ?? null;
}
function topBlocks(): HTMLElement[] {
return Array.from(content.children).filter(
(el): el is HTMLElement => el instanceof HTMLElement && el.hasAttribute('data-palabras-id')
);
}
function firstRowCells(table: HTMLElement): HTMLElement[] {
const row = table.querySelector('tr');
return row
? Array.from(row.children).filter((c): c is HTMLElement => c instanceof HTMLElement)
: [];
}
function remeasure() {
const fr = frame();
if (!fr) {
handles = [];
return;
}
const frameRect = fr.getBoundingClientRect();
const out: Handle[] = [];
topBlocks().forEach((el, tableIdx) => {
if (el.getAttribute('data-palabras-block') !== 'table') return;
const tableRect = el.getBoundingClientRect();
const cells = firstRowCells(el);
for (let c = 0; c < cells.length - 1; c++) {
const cr = cells[c].getBoundingClientRect();
out.push({
key: `${tableIdx}-${c}`,
left: Math.round(cr.right - frameRect.left),
top: Math.round(tableRect.top - frameRect.top),
height: Math.round(tableRect.height),
tableIdx,
colIdx: c
});
}
});
handles = out;
}
// Re-measure on document change (skip mid-drag — the live width is inline).
$effect(() => {
void api.html;
untrack(() => {
if (!dragKey) remeasure();
});
});
// ...and on scroll / resize (positions are viewport-derived).
$effect(() => {
const offScroll = dom.listen(
dom.getDocument(content),
'scroll',
() => {
if (!dragKey) remeasure();
},
{ capture: true }
);
const offResize = dom.listen(dom.getWindow(content), 'resize', () => {
if (!dragKey) remeasure();
});
return () => {
offScroll?.();
offResize?.();
};
});
// Show handles only for the table under the cursor (nothing outside a table).
$effect(() => {
if (!content) return;
const canvas = (content.closest('[data-palabras-canvas]') as HTMLElement | null) ?? content;
const onMove = (e: Event) => {
if (dragKey) return; // frozen during a drag
const t = (e as PointerEvent).target as HTMLElement | null;
if (t?.closest('[data-palabras-col-resize]')) return; // over a handle → keep shown
const table = t?.closest('[data-palabras-block="table"]') as HTMLElement | null;
hoveredKey = table ? String(topBlocks().indexOf(table)) : null;
};
const offMove = dom.listen(canvas, 'pointermove', onMove);
const offLeave = dom.listen(canvas, 'pointerleave', () => {
if (!dragKey) hoveredKey = null;
});
return () => {
offMove?.();
offLeave?.();
};
});
// ── Drag runtime ──────────────────────────────────────────────────────────
let liveCell: HTMLElement | null = null;
let startX = 0;
let startWidth = 0;
let liveWidth = 0;
let dragHandle: Handle | null = null;
let offDragMove: (() => void) | null = null;
let offDragUp: (() => void) | null = null;
function onHandleDown(e: PointerEvent, h: Handle) {
e.preventDefault(); // don't move the caret / start a selection
const table = topBlocks()[h.tableIdx];
liveCell = table ? (firstRowCells(table)[h.colIdx] ?? null) : null;
if (!liveCell) return;
dragHandle = h;
dragKey = h.key;
startX = e.clientX;
startWidth = liveCell.getBoundingClientRect().width;
liveWidth = startWidth;
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
const win = dom.getWindow(content);
offDragMove = dom.listen(win, 'pointermove', onDragMove);
offDragUp = dom.listen(win, 'pointerup', onDragUp);
}
function onDragMove(e: Event) {
if (!liveCell) return;
liveWidth = Math.max(MIN_COL, Math.round(startWidth + ((e as PointerEvent).clientX - startX)));
liveCell.style.width = `${liveWidth}px`;
}
function onDragUp() {
offDragMove?.();
offDragUp?.();
offDragMove = null;
offDragUp = null;
// Only commit a REAL resize (a bare click / double-click never widens).
if (dragHandle && liveCell && liveWidth !== Math.round(startWidth)) {
liveCell.style.width = ''; // drop the transient inline width; the re-render applies the model
// `cell.width` is a CSS LENGTH STRING (the panel's `length` field commits
// e.g. "314px"); a bare number renders as an invalid `width:314`.
api.applyCommand({
type: 'updateBlockAtPath',
blockPath: [dragHandle.tableIdx, 0, dragHandle.colIdx],
patch: { width: `${liveWidth}px` }
});
} else if (liveCell) {
liveCell.style.width = '';
}
liveCell = null;
dragHandle = null;
dragKey = null;
remeasure();
}
function onHandleDblClick(h: Handle) {
// Auto — clear the column's declared width.
api.applyCommand({
type: 'updateBlockAtPath',
blockPath: [h.tableIdx, 0, h.colIdx],
patch: { width: undefined }
});
}
</script>
{#each handles as h (h.key)}
<div
data-palabras-col-resize
data-palabras-external-tool
data-dragging={dragKey === h.key ? '' : undefined}
data-visible={hoveredKey === String(h.tableIdx) || dragKey === h.key ? '' : undefined}
style="left: {h.left}px; top: {h.top}px; height: {h.height}px"
role="separator"
aria-orientation="vertical"
aria-label={ts('#?components.palabras.action.resize-column|Redimensionar columna')}
onpointerdown={(e) => onHandleDown(e, h)}
ondblclick={() => onHandleDblClick(h)}
></div>
{/each}

@ -29,6 +29,7 @@
import PalabrasMenubar from './palabras-menubar.svelte';
import PalabrasPanel from './palabras-panel.svelte';
import PalabrasStatus from './palabras-status.svelte';
import PalabrasTableResize from './palabras-table-resize.svelte';
import { mergePalabrasScheme, resolvePalabrasScheme, schemeCssVars } from './palabras-scheme';
import type { PalabrasProps, PalabrasScheme } from './types';
@ -191,6 +192,9 @@
<!-- A "+" over each EMPTY column → the block-type grid → inserts into
that column (Palabras' `insertBlockInColumn`). -->
<PalabrasColumnInserter {api} content={contentEl} {dom} {scheme} />
<!-- Column resize (F4.3b) — drag the boundary handles on a table's
top edge; double-click = auto. -->
<PalabrasTableResize {api} content={contentEl} {dom} />
{/if}
</div>
</div>

Loading…
Cancel
Save

Powered by TurnKey Linux.