palabras(F4.3): UI de combinar/dividir celdas (menú Editar + pie del panel)

Expone los comandos mergeTableCells/splitTableCell del motor en el chrome,
gateados a celda activa de tabla top-level.

Desviación declarada respecto a "solo UI": los comandos resolvían la celda vía
`state.selection`, que el chrome PIERDE al abrirse fuera del editor (el menú
Editar / el panel). Ajuste mínimo del motor, consistente con mover/eliminar
(que ya operan por `loc.path`): mergeTableCells/splitTableCell aceptan un `at`
explícito `[block,row,cell]` (fallback a la selección). Test nuevo con
`selection: null`.

UI (eidos):
- palabras-block-actions.ts: `tableCellActions(loc, activeEl)` deriva inCell +
  `at` (desde loc) + canMergeRight/Down/Split (geometría del <td> renderizado) +
  dispatch `mergeActiveCells`/`splitActiveCell`.
- Menú Editar (barra): 3 items gateados (Combinar derecha `cols:2` / abajo
  `rows:2` / Dividir).
- Panel: fila de acciones de celda PROPIA — el `blockFoot` (mover/insertar) se
  oculta para celdas structural, así que las celdas tienen su pie.

Restringido a tablas top-level (el resolver del motor direcciona
[block,row,cell]). Langs es/en de las 3 acciones DIFERIDOS (fichero de langs
entangled con el merge pendiente; los `ts()` renderizan por fallback).

Verificado: motor 37 tests (2 nuevos del `at`); check 0 errores palabras/table;
Playwright headless en AMBAS superficies: barra Editar → Combinar derecha →
celda colspan=2 (B1 absorbida) → Dividir la restaura; pie del panel acoplado
igual. 0 errores de consola.

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

@ -6,7 +6,7 @@
* (re-pointing the active block after a delete) in one place.
*/
import { tick } from 'svelte';
import type { ProviderSnippetProps } from '$soma/components/palabras';
import type { ProviderSnippetProps, PalabrasBlockLocation } from '$soma/components/palabras';
import { blockToInsert } from './palabras-block-types';
import type { PalabrasBlockType } from './palabras-block-types';
@ -121,3 +121,63 @@ export function blockSiblingInfo(el: HTMLElement | null): { index: number; count
);
return { index: sibs.indexOf(el), count: sibs.length };
}
/** Merge/split affordances for the active table cell (F4.3), shared by the
* menubar `Editar` menu and the panel's cell-action row. Gates read off the
* rendered `<td>` geometry; the engine ops still no-op when the geometry
* forbids it, so these only hide dead controls. Restricted to TOP-LEVEL tables
* — the engine's cell resolver addresses `[block, row, cell]`, which `at`
* carries from the active block's location (survives the chrome opening away
* from the editor, unlike the live text selection). */
export interface TableCellActions {
readonly inCell: boolean;
/** Anchor cell path `[block, row, cell]` for the engine command. */
readonly at: readonly number[] | null;
readonly canMergeRight: boolean;
readonly canMergeDown: boolean;
readonly canSplit: boolean;
}
const NO_CELL_ACTIONS: TableCellActions = {
inCell: false,
at: null,
canMergeRight: false,
canMergeDown: false,
canSplit: false
};
export function tableCellActions(
loc: PalabrasBlockLocation | null,
activeEl: HTMLElement | null
): TableCellActions {
// Only top-level tables: the first non-document ancestor must be the table.
if (!loc || loc.ancestry[1]?.kind !== 'table' || loc.path.length < 3) return NO_CELL_ACTIONS;
const cell = (activeEl?.closest('td, th') as HTMLTableCellElement | null) ?? null;
if (!cell) return NO_CELL_ACTIONS;
const row = cell.closest('tr');
return {
inCell: true,
at: loc.path.slice(0, 3),
canMergeRight: !!cell.nextElementSibling,
canMergeDown: !!row?.nextElementSibling,
canSplit: cell.colSpan > 1 || cell.rowSpan > 1
};
}
/** Merge the active cell with its right / bottom neighbour (colspan / rowspan 2). */
export function mergeActiveCells(
api: ProviderSnippetProps,
at: readonly number[],
direction: 'right' | 'down'
): void {
api.applyCommand({
type: 'mergeTableCells',
at,
...(direction === 'right' ? { cols: 2 } : { rows: 2 })
});
}
/** Split the active spanned cell back to 1×1. */
export function splitActiveCell(api: ProviderSnippetProps, at: readonly number[]): void {
api.applyCommand({ type: 'splitTableCell', at });
}

@ -15,8 +15,25 @@
import type { ProviderSnippetProps } from '$soma/components/palabras';
import type { Snippet } from 'svelte';
import { insertableBlockTypes } from './palabras-block-types';
import { blockSiblingInfo, deleteBlockAndReanchor, insertTypeAtActive } from './palabras-block-actions';
import { ArrowBigUp, ArrowBigDown, Copy, ClipboardPaste, Trash2, Undo2, Redo2 } from '$uix/eidos/components/icon';
import {
blockSiblingInfo,
deleteBlockAndReanchor,
insertTypeAtActive,
tableCellActions,
mergeActiveCells,
splitActiveCell
} from './palabras-block-actions';
import {
ArrowBigUp,
ArrowBigDown,
Copy,
ClipboardPaste,
Trash2,
Undo2,
Redo2,
TableCellsMerge,
TableCellsSplit
} from '$uix/eidos/components/icon';
import { Button } from '$uix/eidos/components/button';
import { Breadcrumb } from '$uix/eidos/components/breadcrumb';
import { crumbLabel } from './panel-schema';
@ -91,6 +108,10 @@
const sibInfo = $derived(blockSiblingInfo(activeEl));
const canMoveUp = $derived(sibInfo.index > 0);
const canMoveDown = $derived(sibInfo.index >= 0 && sibInfo.index < sibInfo.count - 1);
// F4.3 — table-cell merge/split, shown only when the caret is inside a
// top-level table cell. `at` rides the active block location (the live text
// selection is gone once this menu opens).
const cellActions = $derived(tableCellActions(loc, activeEl));
function doMove(direction: 'up' | 'down') {
if (!loc) return;
@ -179,6 +200,30 @@
<Trash2 />
<span>{ts('#?components.palabras.action.delete|Borrar')}</span>
</Menubar.Item>
{#if cellActions.inCell}
<Menubar.Separator />
<Menubar.Item
disabled={!cellActions.canMergeRight}
onSelect={() => cellActions.at && mergeActiveCells(api, cellActions.at, 'right')}
>
<TableCellsMerge />
<span>{ts('#?components.palabras.action.merge-right|Combinar con la derecha')}</span>
</Menubar.Item>
<Menubar.Item
disabled={!cellActions.canMergeDown}
onSelect={() => cellActions.at && mergeActiveCells(api, cellActions.at, 'down')}
>
<TableCellsMerge />
<span>{ts('#?components.palabras.action.merge-down|Combinar con la de abajo')}</span>
</Menubar.Item>
<Menubar.Item
disabled={!cellActions.canSplit}
onSelect={() => cellActions.at && splitActiveCell(api, cellActions.at)}
>
<TableCellsSplit />
<span>{ts('#?components.palabras.action.split-cell|Dividir celda')}</span>
</Menubar.Item>
{/if}
</Menubar.Content>
</Menubar.Menu>

@ -41,7 +41,9 @@
ArrowBigDown,
Plus,
RotateCcw,
Download
Download,
TableCellsMerge,
TableCellsSplit
} from '$uix/eidos/components/icon';
import { getPanelSchema, crumbLabel, PALABRAS_FONTS, PALABRAS_LEADING } from './panel-schema';
import {
@ -76,7 +78,10 @@
import {
blockSiblingInfo,
deleteBlockAndReanchor,
insertTypeAtActive
insertTypeAtActive,
tableCellActions,
mergeActiveCells,
splitActiveCell
} from './palabras-block-actions';
import type { PalabrasFieldDef, PalabrasSectionDef } from './types';
import { ActiveEidos } from '$uix/eidos';
@ -814,6 +819,10 @@
const sibInfo = $derived(blockSiblingInfo(anchorEl));
const canMoveUp = $derived(sibInfo.index > 0);
const canMoveDown = $derived(sibInfo.index >= 0 && sibInfo.index < sibInfo.count - 1);
// F4.3 — table-cell merge/split. The block footer hides for structural cells
// (move/insert don't apply), so cells get their OWN action row. `at` rides the
// active block location, not the live selection.
const cellActions = $derived(tableCellActions(loc, anchorEl));
function doMoveUp() {
if (!loc || !canMoveUp) return;
@ -1197,6 +1206,51 @@
{/if}
{/snippet}
<!-- Table-cell action row (F4.3) — merge with the right / bottom neighbour or
split a spanned cell. Its own row because `blockFoot` hides for structural
cells (they can't be moved / inserted as sibling blocks). -->
{#snippet cellFoot()}
{#if cellActions.inCell}
<div data-palabras-panel-foot data-palabras-cell-foot>
<Button
variant="ghost"
size="sm"
iconOnly
disabled={!cellActions.canMergeRight}
aria-label={ts('#?components.palabras.action.merge-right|Combinar con la derecha')}
title={ts('#?components.palabras.action.merge-right|Combinar con la derecha')}
onclick={() => cellActions.at && mergeActiveCells(api, cellActions.at, 'right')}
>
{#snippet icon()}<TableCellsMerge />{/snippet}
</Button>
<Button
variant="ghost"
size="sm"
iconOnly
disabled={!cellActions.canMergeDown}
aria-label={ts('#?components.palabras.action.merge-down|Combinar con la de abajo')}
title={ts('#?components.palabras.action.merge-down|Combinar con la de abajo')}
onclick={() => cellActions.at && mergeActiveCells(api, cellActions.at, 'down')}
>
{#snippet icon()}<span style="display:inline-flex;transform:rotate(90deg)"
><TableCellsMerge /></span
>{/snippet}
</Button>
<Button
variant="ghost"
size="sm"
iconOnly
disabled={!cellActions.canSplit}
aria-label={ts('#?components.palabras.action.split-cell|Dividir celda')}
title={ts('#?components.palabras.action.split-cell|Dividir celda')}
onclick={() => cellActions.at && splitActiveCell(api, cellActions.at)}
>
{#snippet icon()}<TableCellsSplit />{/snippet}
</Button>
</div>
{/if}
{/snippet}
<!-- The ONE panel body — every branch (block / document / fallback) renders
through the SHELL indirection, so both presentations share it verbatim. -->
{#snippet panelContent()}
@ -1301,6 +1355,7 @@
</SHELL.Body>
{@render blockFoot()}
{@render cellFoot()}
{:else if view === 'doc' || (view === 'auto' && !loc)}
<!-- DOCUMENT level (Documento crumb / no active block) — the doc PANEL
(§6 row 14, the whole-document lever), v2 confronted against real

@ -224,9 +224,16 @@ export type PalabrasCommand =
| { readonly type: 'deleteTableRow' }
| { readonly type: 'deleteTableColumn' }
// Merge the visual rows×cols rectangle anchored at the active cell
// (colspan/rowspan); split restores a spanned cell to 1×1.
| { readonly type: 'mergeTableCells'; readonly rows?: number; readonly cols?: number }
| { readonly type: 'splitTableCell' }
// (colspan/rowspan); split restores a spanned cell to 1×1. `at` is the
// explicit anchor cell path for chrome that doesn't preserve the live
// selection (menubar / panel); it falls back to the current selection.
| {
readonly type: 'mergeTableCells';
readonly rows?: number;
readonly cols?: number;
readonly at?: readonly number[];
}
| { readonly type: 'splitTableCell'; readonly at?: readonly number[] }
| { readonly type: 'moveTableCell'; readonly direction: 'next' | 'previous' }
| { readonly type: 'toggleTableHeaderRow' }
| { readonly type: 'toggleTableHeaderColumn' }
@ -367,9 +374,9 @@ export function applyPalabrasCommand(
case 'deleteTableColumn':
return deleteTableColumn(state);
case 'mergeTableCells':
return mergeTableCells(state, { rows: command.rows, cols: command.cols });
return mergeTableCells(state, { rows: command.rows, cols: command.cols, at: command.at });
case 'splitTableCell':
return splitTableCell(state);
return splitTableCell(state, command.at);
case 'moveTableCell':
return moveTableCell(state, command.direction);
case 'toggleTableHeaderRow':

@ -445,3 +445,31 @@ describe('splitTableCell', () => {
expect(r.changed).toBe(false);
});
});
// The chrome (menubar `Editar` / panel) drives merge/split by the ACTIVE block's
// cell path, since it opens away from the editor and the live text selection is
// gone. Both ops must honour an explicit `at` with no selection at all.
describe('mergeTableCells / splitTableCell — explicit `at` path (no live selection)', () => {
it('merges via the explicit anchor path when the selection is null', () => {
const d = doc(table3x3());
const r = mergeTableCells(createState(d, null), { cols: 2, at: [0, 0, 0] });
expect(r.changed).toBe(true);
const table = r.state.document.children[0];
if (table.type !== 'table') throw new Error('expected table');
expect(table.rows[0].cells[0].colspan).toBe(2);
expect(cellTexts(table)[0]).toEqual(['00|01', '02']);
});
it('splits via the explicit anchor path when the selection is null', () => {
const merged = mergeTableCells(
createState(doc(table3x3()), createCollapsedSelection([0, 0, 0, 0], 0)),
{ cols: 2 }
);
const r = splitTableCell({ ...merged.state, selection: null }, [0, 0, 0]);
expect(r.changed).toBe(true);
const table = r.state.document.children[0];
if (table.type !== 'table') throw new Error('expected table');
expect(table.rows[0].cells).toHaveLength(3);
expect(table.rows[0].cells[0].colspan).toBeUndefined();
});
});

@ -150,6 +150,10 @@ export interface MergeCellsOptions {
readonly rows?: number;
/** Visual columns the merged cell should span (≥1). */
readonly cols?: number;
/** Explicit anchor cell path `[block, row, cell]`. Used when the caller drives
* the merge from chrome that doesn't preserve the live selection (the menubar
* `Editar` menu / the panel). Falls back to the current selection's cell. */
readonly at?: readonly number[];
}
/**
@ -164,8 +168,9 @@ export function mergeTableCells(
state: PalabrasEditorState,
options: MergeCellsOptions = {}
): PalabrasOperationResult {
const path = currentTableCellPath(state);
if (!path) return noOp(state);
const raw = options.at ?? currentTableCellPath(state);
if (!raw || raw.length < 3) return noOp(state);
const path: TableCellPath = [raw[0]!, raw[1]!, raw[2]!];
const [blockIndex, rowIndex, cellIndex] = path;
const block = state.document.children[blockIndex];
if (!block || block.type !== 'table') return noOp(state);
@ -246,9 +251,13 @@ export function mergeTableCells(
* rows (grid-derived, so it stays correct next to OTHER spanned cells).
* The cell's content stays in the original (top-left) cell.
*/
export function splitTableCell(state: PalabrasEditorState): PalabrasOperationResult {
const path = currentTableCellPath(state);
if (!path) return noOp(state);
export function splitTableCell(
state: PalabrasEditorState,
at?: readonly number[]
): PalabrasOperationResult {
const raw = at ?? currentTableCellPath(state);
if (!raw || raw.length < 3) return noOp(state);
const path: TableCellPath = [raw[0]!, raw[1]!, raw[2]!];
const [blockIndex, rowIndex, cellIndex] = path;
const block = state.document.children[blockIndex];
if (!block || block.type !== 'table') return noOp(state);

Loading…
Cancel
Save

Powered by TurnKey Linux.