From 16db55213ce67c27b42f91619e90c84a6efcb6f9 Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Mon, 18 May 2026 15:52:36 -0400 Subject: [PATCH 1/4] feat(table-handles): implement table handles and popovers for column and row actions v1 --- .../table-column-popover.component.ts | 193 ++++++++++++++++++ .../table-handles/table-handles.component.ts | 175 ++++++++++++++++ .../table-row-popover.component.ts | 122 +++++++++++ .../table-properties-popover.component.ts | 165 +++++++++++++++ .../toolbar/editor-toolbar.store.ts | 9 +- .../components/toolbar/toolbar.component.html | 164 ++------------- .../components/toolbar/toolbar.component.ts | 71 +++---- .../src/lib/editor/editor.component.css | 36 ++++ .../src/lib/editor/editor.component.ts | 14 ++ .../editor/extensions/editor-extensions.ts | 12 +- .../lib/editor/extensions/table-extensions.ts | 125 ++++++++++++ .../table-scope-auto-assign.plugin.ts | 106 ++++++++++ .../extensions/table-selection.plugin.ts | 137 +++++++++++++ .../editor/services/editor-popover.service.ts | 83 +++++++- .../editor/services/table-handles.store.ts | 62 ++++++ .../WEB-INF/messages/Language.properties | 31 +++ 16 files changed, 1304 insertions(+), 201 deletions(-) create mode 100644 core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-column-popover.component.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-handles.component.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-row-popover.component.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/components/table-properties-popover/table-properties-popover.component.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/extensions/table-extensions.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/extensions/table-scope-auto-assign.plugin.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/extensions/table-selection.plugin.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/services/table-handles.store.ts diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-column-popover.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-column-popover.component.ts new file mode 100644 index 00000000000..8605f22dfdd --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-column-popover.component.ts @@ -0,0 +1,193 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + signal, + untracked +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { Select } from 'primeng/select'; + +import { Editor } from '@tiptap/core'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { EditorPopoverService } from '../../services/editor-popover.service'; +import { EditorPopoverComponent } from '../editor-popover/editor-popover.component'; + +/** + * Column-scoped popover, opened from the column handle. Provides the actions that apply + * to the entire column the user is hovering: insert left, insert right, toggle header, + * delete, plus a header-scope select that only appears when the anchor cell is a ``. + * + * The anchor cell position is snapshotted in the popover payload so the actions still + * target the right column even if the cursor wanders while the popover is open. + */ +@Component({ + selector: 'dot-table-column-popover', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FormsModule, Select, EditorPopoverComponent, DotMessagePipe], + template: ` + + + + `, + styles: [ + ` + .popover-item { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + color: rgb(55 65 81); + cursor: pointer; + background: transparent; + border: none; + text-align: left; + } + .popover-item:hover { + background: rgb(238 242 255); + } + .popover-item--danger { + color: rgb(185 28 28); + } + .popover-item--danger:hover { + background: rgb(254 226 226); + } + .popover-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + } + .popover-row__label { + font-size: 0.75rem; + color: rgb(75 85 99); + white-space: nowrap; + } + ` + ] +}) +export class TableColumnPopoverComponent { + readonly editor = input.required(); + protected readonly manager = inject(EditorPopoverService); + private readonly dotMessageService = inject(DotMessageService); + + protected readonly scope = signal(''); + protected readonly showScope = computed( + () => this.manager.tableColumnPayload()?.isHeader ?? false + ); + + protected readonly scopeOptions: ReadonlyArray<{ label: string; value: string }>; + + constructor() { + const msg = (key: string) => this.dotMessageService.get(key); + this.scopeOptions = [ + { label: msg('dot.block.editor.toolbar.table.scope.auto'), value: '' }, + { label: msg('dot.block.editor.toolbar.table.scope.col'), value: 'col' }, + { label: msg('dot.block.editor.toolbar.table.scope.row'), value: 'row' }, + { label: msg('dot.block.editor.toolbar.table.scope.colgroup'), value: 'colgroup' }, + { label: msg('dot.block.editor.toolbar.table.scope.rowgroup'), value: 'rowgroup' } + ]; + + // Seed the scope value from the payload whenever the popover opens. + effect(() => { + const payload = this.manager.tableColumnPayload(); + const open = this.manager.isOpen('table-column'); + untracked(() => { + if (open && payload) { + this.scope.set(payload.headerScope); + } + }); + }); + } + + protected action(event: MouseEvent, fn: () => void): void { + event.preventDefault(); + event.stopPropagation(); + fn(); + this.manager.close(); + } + + /** Place selection inside the anchor cell, then run a TipTap chain. */ + private withCell(chain: (editor: Editor) => void): void { + const payload = this.manager.tableColumnPayload(); + if (!payload) return; + const editor = this.editor(); + editor.chain().focus().setTextSelection(payload.cellPos + 1).run(); + chain(editor); + } + + protected insertLeft = (): void => { + this.withCell((editor) => editor.chain().focus().addColumnBefore().run()); + }; + + protected insertRight = (): void => { + this.withCell((editor) => editor.chain().focus().addColumnAfter().run()); + }; + + protected toggleHeader = (): void => { + this.withCell((editor) => editor.chain().focus().toggleHeaderColumn().run()); + }; + + protected deleteColumn = (): void => { + this.withCell((editor) => editor.chain().focus().deleteColumn().run()); + }; + + protected onScopeChange(value: string): void { + const payload = this.manager.tableColumnPayload(); + if (!payload) return; + this.editor() + .chain() + .focus() + .setTextSelection(payload.cellPos + 1) + .updateAttributes('tableHeader', { scope: value === '' ? null : value }) + .run(); + } +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-handles.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-handles.component.ts new file mode 100644 index 00000000000..a2b864fdadd --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-handles.component.ts @@ -0,0 +1,175 @@ +import { autoUpdate, computePosition, shift } from '@floating-ui/dom'; + +import { + ChangeDetectionStrategy, + Component, + ElementRef, + NgZone, + OnDestroy, + computed, + effect, + inject, + input +} from '@angular/core'; + +import { Editor } from '@tiptap/core'; + +import { DotMessageService } from '@dotcms/data-access'; + +import { EditorPopoverService } from '../../services/editor-popover.service'; +import { ActiveCell, TableHandlesStore } from '../../services/table-handles.store'; + +interface PositionedHandle { + el: HTMLElement; + placement: 'top' | 'left'; +} + +/** + * Renders two floating handles — column (top of column) and row (left of row) — anchored + * to the cell containing the editor cursor. Driven by {@link TableHandlesStore.activeCell}, + * which {@link TableSelectionPlugin} updates whenever the cursor moves between cells. + * + * Each handle button uses `mousedown.preventDefault()` so clicking it does NOT move the + * editor selection — the cursor stays in the cell, the popover opens, and after the popover + * closes the user can keep typing without re-clicking the cell. + * + * Phase 3: dropped the third (table-actions) handle and the hover-driven lock state. Table + * a11y properties are now reached from the toolbar's `table_edit` button instead. + */ +@Component({ + selector: 'dot-table-handles', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '[style.display]': 'isVisible() ? "contents" : "none"' + }, + template: ` + + + ` +}) +export class TableHandlesComponent implements OnDestroy { + readonly editor = input.required(); + + private readonly store = inject(TableHandlesStore); + private readonly popovers = inject(EditorPopoverService); + private readonly el = inject(ElementRef); + private readonly zone = inject(NgZone); + private readonly dotMessageService = inject(DotMessageService); + + protected readonly columnAriaLabel = this.dotMessageService.get( + 'dot.block.editor.table.handle.column.aria-label' + ); + protected readonly rowAriaLabel = this.dotMessageService.get( + 'dot.block.editor.table.handle.row.aria-label' + ); + + protected readonly isVisible = computed(() => this.store.activeCell() !== null); + + private autoUpdateDispose: Array<() => void> = []; + + constructor() { + effect(() => { + const active = this.store.activeCell(); + this.teardownAutoUpdate(); + if (!active) return; + + for (const handle of this.collectHandles()) { + const refEl = pickReference(handle, active); + if (!refEl) continue; + this.autoUpdateDispose.push( + autoUpdate( + refEl, + handle.el, + () => this.applyPosition(handle.el, refEl, handle.placement), + { ancestorScroll: true, ancestorResize: true, elementResize: true } + ) + ); + } + }); + } + + ngOnDestroy(): void { + this.teardownAutoUpdate(); + } + + private collectHandles(): PositionedHandle[] { + const root = this.el.nativeElement as HTMLElement; + const buttons = root.querySelectorAll('.table-handle'); + if (buttons.length < 2) return []; + return [ + { el: buttons[0] as HTMLElement, placement: 'top' }, + { el: buttons[1] as HTMLElement, placement: 'left' } + ]; + } + + private applyPosition(el: HTMLElement, refEl: HTMLElement, placement: 'top' | 'left'): void { + void computePosition(refEl, el, { + placement: placement === 'top' ? 'top' : 'left', + strategy: 'fixed', + middleware: [shift({ padding: 4 })] + }).then(({ x, y }) => { + this.zone.run(() => { + el.style.position = 'fixed'; + el.style.left = `${x}px`; + el.style.top = `${y}px`; + }); + }); + } + + private teardownAutoUpdate(): void { + for (const dispose of this.autoUpdateDispose) dispose(); + this.autoUpdateDispose = []; + } + + // ── Popover openers ────────────────────────────────────────────────────── + + protected openColumn(anchor: HTMLElement): void { + const active = this.store.activeCell(); + if (!active) return; + this.popovers.openTableColumn(() => anchor.getBoundingClientRect(), { + cellPos: active.cellPos, + isHeader: active.isHeader, + headerScope: this.readScope(active) + }); + } + + protected openRow(anchor: HTMLElement): void { + const active = this.store.activeCell(); + if (!active) return; + this.popovers.openTableRow(() => anchor.getBoundingClientRect(), { + cellPos: active.cellPos + }); + } + + private readScope(active: ActiveCell): string { + const node = this.editor().state.doc.nodeAt(active.cellPos); + if (!node || node.type.name !== 'tableHeader') return ''; + return (node.attrs['scope'] as string | null) ?? ''; + } +} + +function pickReference(handle: PositionedHandle, active: ActiveCell): HTMLElement | null { + if (handle.placement === 'top' && handle.el.classList.contains('table-handle--column')) { + return active.columnHeadEl; + } + if (handle.placement === 'left' && handle.el.classList.contains('table-handle--row')) { + return active.rowHeadEl; + } + return null; +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-row-popover.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-row-popover.component.ts new file mode 100644 index 00000000000..064815c60a9 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-row-popover.component.ts @@ -0,0 +1,122 @@ +import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; + +import { Editor } from '@tiptap/core'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { EditorPopoverService } from '../../services/editor-popover.service'; +import { EditorPopoverComponent } from '../editor-popover/editor-popover.component'; + +/** + * Row-scoped popover, opened from the row handle. Insert above / below, toggle the row + * as a header row, delete the row. Operates on the cell whose `cellPos` is in the popover + * payload — snapshotted at open time so concurrent editor selection changes don't move the + * target out from under the user. + */ +@Component({ + selector: 'dot-table-row-popover', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [EditorPopoverComponent, DotMessagePipe], + template: ` + + + + `, + styles: [ + ` + .popover-item { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + color: rgb(55 65 81); + cursor: pointer; + background: transparent; + border: none; + text-align: left; + } + .popover-item:hover { + background: rgb(238 242 255); + } + .popover-item--danger { + color: rgb(185 28 28); + } + .popover-item--danger:hover { + background: rgb(254 226 226); + } + ` + ] +}) +export class TableRowPopoverComponent { + readonly editor = input.required(); + protected readonly manager = inject(EditorPopoverService); + + protected action(event: MouseEvent, fn: () => void): void { + event.preventDefault(); + event.stopPropagation(); + fn(); + this.manager.close(); + } + + private withCell(chain: (editor: Editor) => void): void { + const payload = this.manager.tableRowPayload(); + if (!payload) return; + const editor = this.editor(); + editor + .chain() + .focus() + .setTextSelection(payload.cellPos + 1) + .run(); + chain(editor); + } + + protected insertAbove = (): void => { + this.withCell((editor) => editor.chain().focus().addRowBefore().run()); + }; + + protected insertBelow = (): void => { + this.withCell((editor) => editor.chain().focus().addRowAfter().run()); + }; + + protected toggleHeader = (): void => { + this.withCell((editor) => editor.chain().focus().toggleHeaderRow().run()); + }; + + protected deleteRow = (): void => { + this.withCell((editor) => editor.chain().focus().deleteRow().run()); + }; +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/table-properties-popover/table-properties-popover.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/table-properties-popover/table-properties-popover.component.ts new file mode 100644 index 00000000000..5e7ee6ca0d0 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/table-properties-popover/table-properties-popover.component.ts @@ -0,0 +1,165 @@ +import { + ChangeDetectionStrategy, + Component, + effect, + inject, + input, + untracked +} from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; + +import { Editor } from '@tiptap/core'; + +import { DotMessagePipe } from '@dotcms/ui'; + +import { EditorPopoverService } from '../../services/editor-popover.service'; +import { EditorPopoverComponent } from '../editor-popover/editor-popover.component'; + +const EMPTY_VALUES = { + caption: '', + hasCaption: false, + ariaLabel: '', + ariaLabelledby: '' +}; + +/** + * Toolbar-anchored a11y popover for the active table. Edits: + * + * - **Caption** — sets the table's `caption` attribute (rendered as a `` child). + * - **aria-label** — accessible name for the ``. + * - **aria-labelledby** — references an `id` of an external label. + * + * Phase 3: stripped down from the prior `TableActionsPopover` — merge / split / delete-table + * were dropped as out-of-scope for the a11y ticket. Opened from the new `table_edit` toolbar + * button only when the cursor is inside a table. + */ +@Component({ + selector: 'dot-table-properties-popover', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ReactiveFormsModule, EditorPopoverComponent, DotMessagePipe], + template: ` + +
+
+

+ {{ 'dot.block.editor.dialog.table-properties.title' | dm }} +

+ + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ ` +}) +export class TablePropertiesPopoverComponent { + readonly editor = input.required(); + protected readonly manager = inject(EditorPopoverService); + + readonly form = new FormGroup({ + hasCaption: new FormControl(false, { nonNullable: true }), + caption: new FormControl('', { nonNullable: true }), + ariaLabel: new FormControl('', { nonNullable: true }), + ariaLabelledby: new FormControl('', { nonNullable: true }) + }); + + constructor() { + effect(() => { + const payload = this.manager.tablePropertiesPayload(); + const open = this.manager.isOpen('table-properties'); + + untracked(() => { + if (open && payload) { + this.form.reset(payload.initialValues); + } else if (!open) { + this.form.reset(EMPTY_VALUES); + } + }); + }); + } + + onApply(): void { + const { hasCaption, caption, ariaLabel, ariaLabelledby } = this.form.getRawValue(); + + // All three a11y fields are stored as table attributes — set them in one shot. + this.editor() + .chain() + .focus() + .updateAttributes('table', { + caption: hasCaption && caption.trim() ? caption.trim() : null, + ariaLabel: ariaLabel?.trim() ? ariaLabel.trim() : null, + ariaLabelledby: ariaLabelledby?.trim() ? ariaLabelledby.trim() : null + }) + .run(); + + this.manager.close(); + } +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/editor-toolbar.store.ts b/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/editor-toolbar.store.ts index b038a46ddd9..4f7540c8410 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/editor-toolbar.store.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/editor-toolbar.store.ts @@ -29,9 +29,9 @@ export class EditorToolbarStore { readonly textAlign = signal<'left' | 'center' | 'right' | 'justify'>('left'); readonly isSuperscript = signal(false); readonly isSubscript = signal(false); + /** True when the editor selection is anywhere inside a table — drives the toolbar's + * `table_edit` (Table properties) button's enabled state. */ readonly isInTable = signal(false); - readonly canMergeCells = signal(false); - readonly canSplitCell = signal(false); readonly selectedContentlet = signal(null); connect(editor: Editor): () => void { @@ -84,10 +84,7 @@ export class EditorToolbarStore { ); this.isSuperscript.set(editor.isActive('superscript')); this.isSubscript.set(editor.isActive('subscript')); - this.isInTable.set(editor.isActive('table')); - this.canMergeCells.set(editor.can().mergeCells()); - this.canSplitCell.set(editor.can().splitCell()); - + this.isInTable.set(inTable); const { selection } = editor.state; const contentletNode = selection instanceof NodeSelection && selection.node.type.name === 'dotContent' diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/toolbar.component.html b/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/toolbar.component.html index 046fce0fe90..40ff2837367 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/toolbar.component.html +++ b/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/toolbar.component.html @@ -425,168 +425,28 @@ (mousedown)="openTableDialog($event)"> - } - - @if (isAllowed('table')) { - - - - - - - - - - - - - - - - - - - - - - - - - } + + @if (isAllowed('emoji')) {
` child element by + * the `renderHTML` override below. It's not contenteditable from the canvas — authors set + * it from the toolbar `table_edit` popover. + */ +const DotTable = Table.extend({ + addAttributes() { + return { + ...this.parent?.(), + caption: { + default: null, + parseHTML: (element) => { + const captionEl = element.querySelector(':scope > caption'); + return captionEl?.textContent?.trim() || null; + }, + // Not a HTML attribute — emitted as a
child by the + // node-level renderHTML override below. + renderHTML: () => ({}) + }, + ariaLabel: { + default: null, + parseHTML: (element) => element.getAttribute('aria-label'), + renderHTML: (attributes) => { + const value = attributes['ariaLabel']; + if (value == null || value === '') return {}; + return { 'aria-label': value }; + } + }, + ariaLabelledby: { + default: null, + parseHTML: (element) => element.getAttribute('aria-labelledby'), + renderHTML: (attributes) => { + const value = attributes['ariaLabelledby']; + if (value == null || value === '') return {}; + return { 'aria-labelledby': value }; + } + } + }; + }, + + /** + * Mirrors `@tiptap/extension-table`'s upstream `renderHTML` (preserves the `colgroup` + * generation that drives column resizing) and splices in a `` element when + * `attrs.caption` is set. HTML spec ordering: ` > ? > + * ` — caption MUST come first inside `
? >
`. + * + * Keep in sync with upstream if `@tiptap/extension-table` changes its renderer. + */ + renderHTML({ node, HTMLAttributes }) { + const { colgroup, tableWidth, tableMinWidth } = createColGroup( + node, + this.options.cellMinWidth + ); + const userStyles = HTMLAttributes['style']; + const style = + userStyles ?? (tableWidth ? `width: ${tableWidth}` : `min-width: ${tableMinWidth}`); + const caption = (node.attrs['caption'] as string | null)?.trim(); + const attrs = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { style }); + + const table = caption + ? (['table', attrs, ['caption', caption], colgroup, ['tbody', 0]] as const) + : (['table', attrs, colgroup, ['tbody', 0]] as const); + return this.options.renderWrapper ? ['div', { class: 'tableWrapper' }, table] : table; + } +}); + +/** + * Adds `scope` to `
` cells. The `TableScopeAutoAssign` ProseMirror plugin (registered + * below) fills this attribute in based on cell position; an author can still override the + * value to `colgroup` / `rowgroup` from the column popover — auto-assign skips non-null values. + */ +const DotTableHeader = TableHeader.extend({ + addAttributes() { + return { + ...this.parent?.(), + scope: { + default: null, + parseHTML: (element) => element.getAttribute('scope'), + renderHTML: (attributes) => { + const value = attributes['scope']; + if (value == null || value === '') return {}; + return { scope: value }; + } + } + }; + } +}); + +interface DotTableKitOptions { + /** Forwarded to the underlying `Table` config (e.g. `{ resizable: true }`). */ + table?: Parameters[0]; +} + +/** + * Returns the full set of table-related TipTap extensions: + * + * - `DotTable` — Table extended with `caption` + `ariaLabel` + `ariaLabelledby` attributes + * (overrides `TableKit.table`). Caption is rendered as a `
` child element. + * - `DotTableHeader` — TableHeader extended with `scope` (overrides `TableKit.tableHeader`). + * - `TableCell` + `TableRow` — supplied unchanged by `TableKit`. + * - `TableScopeAutoAssign` — fills `scope` on header cells based on their position. + */ +export function createDotTableExtensions(options: DotTableKitOptions = {}) { + return [ + // The kit still provides TableRow + TableCell + ProseMirror table editing plugins. + // We disable its table + tableHeader entries because we provide extended versions + // below — leaving them enabled would register two nodes with the same name. + TableKit.configure({ + table: false, + tableHeader: false + }), + DotTable.configure(options.table ?? {}), + DotTableHeader, + TableScopeAutoAssign + ]; +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/table-scope-auto-assign.plugin.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/table-scope-auto-assign.plugin.ts new file mode 100644 index 00000000000..b581e1fe704 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/table-scope-auto-assign.plugin.ts @@ -0,0 +1,106 @@ +import { Extension } from '@tiptap/core'; +import { Node as PMNode } from '@tiptap/pm/model'; +import { EditorState, Plugin, PluginKey, Transaction } from '@tiptap/pm/state'; + +const PLUGIN_KEY = new PluginKey('tableScopeAutoAssign'); + +/** + * Walks every `` in the document and stages `scope` attribute updates on `
` cells + * whose `scope` is still unset. Returns the transaction when at least one cell was updated, + * or `null` when the doc is already in a consistent state (so callers can skip dispatching). + */ +function buildAutoScopeTransaction(state: EditorState): Transaction | null { + const tr = state.tr; + let changed = false; + + state.doc.descendants((node, pos): boolean => { + if (node.type.name !== 'table') return true; + + node.forEach((row, rowOffset, rowIndex) => { + const isFirstRow = rowIndex === 0; + const rowStart = pos + 1 + rowOffset; + + row.forEach((cell, cellOffset, cellIndex) => { + if (cell.type.name !== 'tableHeader') return; + + const existing = cell.attrs['scope']; + if (existing != null && existing !== '') return; + + const desired = isFirstRow ? 'col' : cellIndex === 0 ? 'row' : null; + if (!desired) return; + + const cellPos = rowStart + 1 + cellOffset; + tr.setNodeAttribute(cellPos, 'scope', desired); + changed = true; + }); + }); + + // Children visited via node.forEach above — no need to descend further. + return false; + }); + + if (!changed) return null; + tr.setMeta(PLUGIN_KEY, true); + return tr; +} + +/** + * Auto-fills `scope` on `` cells based on their position so screen readers can + * resolve column-vs-row header relationships (WCAG 1.3.1). + * + * - first row → `scope="col"` + * - first column (when the cell is a ``) → `scope="row"` + * + * Runs in `appendTransaction` only after doc-changing transactions, and is **idempotent**: + * it never touches a cell whose `scope` is already a non-null string. That preserves any + * value the author set explicitly (e.g. `colgroup`, `rowgroup`) via the toolbar select. + * + * Skipped when the previous transaction was generated by this plugin (`tr.getMeta` guard) + * to avoid recursive amplification when several tables exist in the same doc. + */ +export const TableScopeAutoAssign = Extension.create({ + name: 'tableScopeAutoAssign', + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: PLUGIN_KEY, + /** + * Runs once when the editor view mounts. ProseMirror builds the initial + * state directly from the schema — that build does not flow through any + * transaction, so `appendTransaction` would otherwise never see legacy + * tables already in the doc. + */ + view: (view) => { + const tr = buildAutoScopeTransaction(view.state); + if (tr) view.dispatch(tr); + return {}; + }, + appendTransaction: (transactions, _oldState, newState) => { + if (!transactions.some((tr) => tr.docChanged)) return null; + if (transactions.some((tr) => tr.getMeta(PLUGIN_KEY))) return null; + return buildAutoScopeTransaction(newState); + } + }) + ]; + } +}); + +/** + * Exposed for unit-testing: returns the position-derived scope for a given cell + * inside a table node, mirroring the plugin's logic. `null` means leave untouched. + */ +export function computeAutoScope( + table: PMNode, + rowIndex: number, + cellIndex: number +): 'col' | 'row' | null { + if (rowIndex < 0 || rowIndex >= table.childCount) return null; + const row = table.child(rowIndex); + if (cellIndex < 0 || cellIndex >= row.childCount) return null; + const cell = row.child(cellIndex); + if (cell.type.name !== 'tableHeader') return null; + if (rowIndex === 0) return 'col'; + if (cellIndex === 0) return 'row'; + return null; +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/table-selection.plugin.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/table-selection.plugin.ts new file mode 100644 index 00000000000..4947853553c --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/table-selection.plugin.ts @@ -0,0 +1,137 @@ +import { Extension } from '@tiptap/core'; +import { Node as PMNode, ResolvedPos } from '@tiptap/pm/model'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import { TableMap } from '@tiptap/pm/tables'; +import { EditorView } from '@tiptap/pm/view'; + +import type { ActiveCell, TableHandlesStore } from '../services/table-handles.store'; + +const PLUGIN_KEY = new PluginKey('tableSelectionHandles'); + +interface TableSelectionOptions { + store: TableHandlesStore; +} + +/** + * Selection-driven tracker for the floating column / row handles. + * + * Phase 3 replacement for the noisy hover-based tracker. Hooks `Plugin.view(view).update` + * — ProseMirror calls this after every state change (selection move or doc edit) — and + * pushes the cell containing the cursor to {@link TableHandlesStore}. When the cursor + * leaves any table, the store is cleared to `null`. + * + * Short-circuits when the active cell hasn't changed (same `cellPos`) so signal subscribers + * don't churn on every keystroke inside a cell. + */ +export const TableSelectionPlugin = Extension.create({ + name: 'tableSelectionHandles', + + addOptions() { + return { + store: null as unknown as TableHandlesStore + }; + }, + + addProseMirrorPlugins() { + const store = this.options.store; + if (!store) return []; + + return [ + new Plugin({ + key: PLUGIN_KEY, + view: (view) => { + // Initial resolve — the editor may already be inside a table on mount. + const initial = resolveActiveCell(view); + if (initial) store.setActiveCell(initial); + + return { + update: (updatedView) => { + const next = resolveActiveCell(updatedView); + const current = store.activeCell(); + if (next?.cellPos === current?.cellPos) return; + store.setActiveCell(next); + }, + destroy: () => { + store.reset(); + } + }; + } + }) + ]; + } +}); + +function resolveActiveCell(view: EditorView): ActiveCell | null { + const $pos = view.state.selection.$from; + const tableInfo = findTableAt($pos); + if (!tableInfo) return null; + + const { tableNode, tablePos, cellPos, rowPos } = tableInfo; + const map = TableMap.get(tableNode); + const cellOffset = cellPos - tablePos - 1; + const cellIndex = map.map.indexOf(cellOffset); + if (cellIndex < 0) return null; + + const colIndex = cellIndex % map.width; + const rowIndex = Math.floor(cellIndex / map.width); + + const cellDom = view.nodeDOM(cellPos); + if (!(cellDom instanceof HTMLElement)) return null; + const tableEl = cellDom.closest('table'); + if (!(tableEl instanceof HTMLElement)) return null; + + return { + cellPos, + rowPos, + tablePos, + colIndex, + rowIndex, + isHeader: tableNode.nodeAt(cellOffset)?.type.name === 'tableHeader', + cellEl: cellDom, + columnHeadEl: findColumnHeadEl(tableEl, colIndex) ?? cellDom, + rowHeadEl: findRowHeadEl(tableEl, rowIndex) ?? cellDom, + tableEl + }; +} + +interface TableInfo { + tableNode: PMNode; + tablePos: number; + rowPos: number; + cellPos: number; +} + +/** + * Walks up from a resolved position to find the surrounding table + row + cell. Returns + * `null` when the position is not inside a table cell. Used instead of `cellAround` so we + * handle every selection type uniformly (TextSelection, NodeSelection, CellSelection — the + * resolved `$from` always has a depth path we can traverse). + */ +function findTableAt($pos: ResolvedPos): TableInfo | null { + for (let depth = $pos.depth; depth > 0; depth--) { + const node = $pos.node(depth); + if (node.type.name === 'table') { + const tablePos = $pos.before(depth); + const rowDepth = depth + 1; + const cellDepth = depth + 2; + if ($pos.depth < cellDepth) return null; + const rowPos = $pos.before(rowDepth); + const cellPos = $pos.before(cellDepth); + return { tableNode: node, tablePos, rowPos, cellPos }; + } + } + return null; +} + +function findColumnHeadEl(tableEl: HTMLElement, colIndex: number): HTMLElement | null { + const firstRow = tableEl.querySelector(':scope > tbody > tr') as HTMLElement | null; + if (!firstRow) return null; + return (firstRow.children.item(colIndex) as HTMLElement | null) ?? null; +} + +function findRowHeadEl(tableEl: HTMLElement, rowIndex: number): HTMLElement | null { + const rows = tableEl.querySelectorAll(':scope > tbody > tr'); + const row = rows.item(rowIndex) as HTMLElement | null; + if (!row) return null; + return (row.children.item(0) as HTMLElement | null) ?? null; +} diff --git a/core-web/libs/new-block-editor/src/lib/editor/services/editor-popover.service.ts b/core-web/libs/new-block-editor/src/lib/editor/services/editor-popover.service.ts index ee2799eeb2c..20fd8064c66 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/services/editor-popover.service.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/services/editor-popover.service.ts @@ -1,12 +1,52 @@ import { Injectable, NgZone, inject, signal } from '@angular/core'; -export type PopoverId = 'image-properties' | 'link' | 'table' | 'emoji' | 'asset-by-url'; +export type PopoverId = + | 'image-properties' + | 'link' + | 'table' + | 'table-column' + | 'table-row' + | 'table-properties' + | 'emoji' + | 'asset-by-url'; /** Prefill payload for the {@link ImagePropertiesPopoverComponent} (edit-mode). */ export interface ImagePropertiesPayload { initialValues: { src: string; title: string; alt: string }; } +/** + * Prefill payload for the `TablePropertiesPopoverComponent` (caption + aria-label + + * aria-labelledby on the active table). Captures the current values so the form opens + * populated. + */ +export interface TablePropertiesPayload { + initialValues: { + caption: string; + hasCaption: boolean; + ariaLabel: string; + ariaLabelledby: string; + }; +} + +/** + * Prefill payload for the column-scoped popover. `cellPos` anchors edits to a specific + * cell so the popover keeps operating on the same cell even if the user clicks away while + * it's open. + */ +export interface TableColumnPayload { + cellPos: number; + /** True when the anchor cell is a `` — controls visibility of the scope select. */ + isHeader: boolean; + /** Current `scope` attribute of the anchor cell, or `''` when unset. */ + headerScope: string; +} + +/** Prefill payload for the row-scoped popover. */ +export interface TableRowPayload { + cellPos: number; +} + export interface LinkPopoverPayload { initialValues?: { href?: string; @@ -28,9 +68,10 @@ interface ActivePopover { } /** - * Owns the state for caret-anchored editor popovers (link, image-properties, table, emoji). - * Each popover is rendered through the shared `` shell, which subscribes - * to {@link activePopover} and positions itself against the trigger rect via floating-ui. + * Owns the state for caret-anchored editor popovers (link, image-properties, table, emoji, + * and the three table-handle popovers). Each popover is rendered through the shared + * `` shell, which subscribes to {@link activePopover} and positions + * itself against the trigger rect via floating-ui. * * Sibling to {@link EditorModalService}, which owns centered modal dialogs (AI content, * AI image, image picker, video picker). @@ -42,6 +83,9 @@ export class EditorPopoverService { readonly activePopover = signal(null); readonly imagePropertiesPayload = signal(null); readonly linkPayload = signal(null); + readonly tablePropertiesPayload = signal(null); + readonly tableColumnPayload = signal(null); + readonly tableRowPayload = signal(null); /** * **Reactive:** reads {@link activePopover}, so calling this from inside an `effect()` @@ -79,6 +123,37 @@ export class EditorPopoverService { }); } + /** + * Opens the table-properties popover (caption + aria-label + aria-labelledby) anchored + * to the toolbar's `table_edit` button. Populated with the active table's current values. + */ + openTableProperties(clientRectFn: () => DOMRect | null, payload: TablePropertiesPayload): void { + this.zone.run(() => { + this.tablePropertiesPayload.set(payload); + this.activePopover.set({ id: 'table-properties', clientRectFn }); + }); + } + + /** + * Opens the column-scoped popover (insert L/R, toggle col header, header scope, delete + * column) anchored to the column handle. `cellPos` snapshot keeps the popover acting on + * the same column even if the user clicks elsewhere while it's open. + */ + openTableColumn(clientRectFn: () => DOMRect | null, payload: TableColumnPayload): void { + this.zone.run(() => { + this.tableColumnPayload.set(payload); + this.activePopover.set({ id: 'table-column', clientRectFn }); + }); + } + + /** Opens the row-scoped popover (insert above/below, toggle row header, delete row). */ + openTableRow(clientRectFn: () => DOMRect | null, payload: TableRowPayload): void { + this.zone.run(() => { + this.tableRowPayload.set(payload); + this.activePopover.set({ id: 'table-row', clientRectFn }); + }); + } + close(): void { this.zone.run(() => this.activePopover.set(null)); } diff --git a/core-web/libs/new-block-editor/src/lib/editor/services/table-handles.store.ts b/core-web/libs/new-block-editor/src/lib/editor/services/table-handles.store.ts new file mode 100644 index 00000000000..27d068e693b --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/services/table-handles.store.ts @@ -0,0 +1,62 @@ +import { Injectable, NgZone, computed, inject, signal } from '@angular/core'; + +/** + * Snapshot of the cell currently containing the editor cursor. Pushed by + * {@link TableSelectionPlugin} on every relevant `view.update`; consumed by the floating + * column / row handles to compute their anchor positions. + */ +export interface ActiveCell { + /** Document position of the cell node. */ + readonly cellPos: number; + /** Document position of the row that owns the cell. */ + readonly rowPos: number; + /** Document position of the table that owns the row. */ + readonly tablePos: number; + /** Zero-indexed column position within the table. */ + readonly colIndex: number; + /** Zero-indexed row position within the table. */ + readonly rowIndex: number; + /** True when the cell node is `tableHeader` (not `tableCell`). */ + readonly isHeader: boolean; + /** The cell's own DOM element. */ + readonly cellEl: HTMLElement; + /** First-row cell of this column (column handle anchors here). */ + readonly columnHeadEl: HTMLElement; + /** First cell of this row (row handle anchors here). */ + readonly rowHeadEl: HTMLElement; + /** The `` element this cell belongs to. */ + readonly tableEl: HTMLElement; +} + +/** + * Tracks the cell the editor cursor is currently inside. Updated by the selection plugin + * whenever a `view.update` resolves a new cell; cleared to `null` when the cursor leaves + * any table. + * + * Selection-driven (Phase 3) — replaces the hover-driven model from Phase 2. There is no + * grace period or lock state: the handle buttons themselves use `mousedown.preventDefault` + * to keep the cursor inside the cell, so the store's value naturally stays put while the + * user interacts with the floating popovers. + * + * Provided at the editor component scope so each editor instance has its own state. + */ +@Injectable() +export class TableHandlesStore { + private readonly zone = inject(NgZone); + + private readonly _activeCell = signal(null); + + /** Current cell holding the cursor, or `null` when the cursor is outside any table. */ + readonly activeCell = this._activeCell.asReadonly(); + + /** True when the handles should be rendered. */ + readonly isVisible = computed(() => this._activeCell() !== null); + + setActiveCell(cell: ActiveCell | null): void { + this.zone.run(() => this._activeCell.set(cell)); + } + + reset(): void { + this._activeCell.set(null); + } +} diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 36809315a24..dcf014c296b 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -5976,6 +5976,30 @@ dot.block.editor.toolbar.table.toggle-column-header=Toggle column header dot.block.editor.toolbar.table.delete-row=Delete row dot.block.editor.toolbar.table.delete-column=Delete column dot.block.editor.toolbar.table.delete-table=Delete table +dot.block.editor.toolbar.table.properties=Table properties +dot.block.editor.toolbar.table.header-scope=Header scope +dot.block.editor.toolbar.table.scope.auto=Auto +dot.block.editor.toolbar.table.scope.col=Column +dot.block.editor.toolbar.table.scope.row=Row +dot.block.editor.toolbar.table.scope.colgroup=Column group +dot.block.editor.toolbar.table.scope.rowgroup=Row group + +# Table contextual handles (column / row / table-actions popovers) +dot.block.editor.table.handle.column.aria-label=Column actions +dot.block.editor.table.handle.row.aria-label=Row actions +dot.block.editor.table.handle.table.aria-label=Table actions +dot.block.editor.table.column.insert-left=Insert column left +dot.block.editor.table.column.insert-right=Insert column right +dot.block.editor.table.column.toggle-header=Toggle column header +dot.block.editor.table.column.delete=Delete column +dot.block.editor.table.row.insert-above=Insert row above +dot.block.editor.table.row.insert-below=Insert row below +dot.block.editor.table.row.toggle-header=Toggle row header +dot.block.editor.table.row.delete=Delete row +dot.block.editor.table.actions.merge-cells=Merge cells +dot.block.editor.table.actions.split-cell=Split cell +dot.block.editor.table.actions.delete=Delete table + dot.block.editor.toolbar.insert-emoji=Insert emoji dot.block.editor.toolbar.full-screen=Full screen dot.block.editor.toolbar.exit-full-screen=Exit full screen @@ -6017,6 +6041,13 @@ dot.block.editor.dialog.table.field.rows=Rows dot.block.editor.dialog.table.field.columns=Columns dot.block.editor.dialog.table.field.header-row=Include header row +# Table properties dialog +dot.block.editor.dialog.table-properties.title=Table properties +dot.block.editor.dialog.table-properties.caption=Caption +dot.block.editor.dialog.table-properties.aria-label=Accessible name (aria-label) +dot.block.editor.dialog.table-properties.aria-labelledby=Labelled by (aria-labelledby) +dot.block.editor.dialog.table-properties.add-caption=Add caption + # Asset by URL dialog dot.block.editor.dialog.asset-by-url.aria-label=Insert asset by URL dot.block.editor.dialog.asset-by-url.title=Insert asset by URL From c24ad79838933ac188643288a37fb7ad98917c27 Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Mon, 18 May 2026 16:35:20 -0400 Subject: [PATCH 2/4] refactor(table-handles): remove TableHandles component and integrate table handle functionality directly into DotTableCell and DotTableHeader --- .../table-handles/table-handles.component.ts | 175 ------------- .../components/toolbar/toolbar.component.html | 7 +- .../components/toolbar/toolbar.component.ts | 8 +- .../src/lib/editor/editor.component.css | 64 +++-- .../src/lib/editor/editor.component.ts | 5 - .../editor/extensions/editor-extensions.ts | 20 +- .../extensions/table-active-cells.plugin.ts | 113 +++++++++ .../lib/editor/extensions/table-extensions.ts | 231 ++++++++++++++---- .../extensions/table-selection.plugin.ts | 137 ----------- .../editor/services/table-handles.store.ts | 62 ----- 10 files changed, 361 insertions(+), 461 deletions(-) delete mode 100644 core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-handles.component.ts create mode 100644 core-web/libs/new-block-editor/src/lib/editor/extensions/table-active-cells.plugin.ts delete mode 100644 core-web/libs/new-block-editor/src/lib/editor/extensions/table-selection.plugin.ts delete mode 100644 core-web/libs/new-block-editor/src/lib/editor/services/table-handles.store.ts diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-handles.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-handles.component.ts deleted file mode 100644 index a2b864fdadd..00000000000 --- a/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-handles.component.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { autoUpdate, computePosition, shift } from '@floating-ui/dom'; - -import { - ChangeDetectionStrategy, - Component, - ElementRef, - NgZone, - OnDestroy, - computed, - effect, - inject, - input -} from '@angular/core'; - -import { Editor } from '@tiptap/core'; - -import { DotMessageService } from '@dotcms/data-access'; - -import { EditorPopoverService } from '../../services/editor-popover.service'; -import { ActiveCell, TableHandlesStore } from '../../services/table-handles.store'; - -interface PositionedHandle { - el: HTMLElement; - placement: 'top' | 'left'; -} - -/** - * Renders two floating handles — column (top of column) and row (left of row) — anchored - * to the cell containing the editor cursor. Driven by {@link TableHandlesStore.activeCell}, - * which {@link TableSelectionPlugin} updates whenever the cursor moves between cells. - * - * Each handle button uses `mousedown.preventDefault()` so clicking it does NOT move the - * editor selection — the cursor stays in the cell, the popover opens, and after the popover - * closes the user can keep typing without re-clicking the cell. - * - * Phase 3: dropped the third (table-actions) handle and the hover-driven lock state. Table - * a11y properties are now reached from the toolbar's `table_edit` button instead. - */ -@Component({ - selector: 'dot-table-handles', - changeDetection: ChangeDetectionStrategy.OnPush, - host: { - '[style.display]': 'isVisible() ? "contents" : "none"' - }, - template: ` - - - ` -}) -export class TableHandlesComponent implements OnDestroy { - readonly editor = input.required(); - - private readonly store = inject(TableHandlesStore); - private readonly popovers = inject(EditorPopoverService); - private readonly el = inject(ElementRef); - private readonly zone = inject(NgZone); - private readonly dotMessageService = inject(DotMessageService); - - protected readonly columnAriaLabel = this.dotMessageService.get( - 'dot.block.editor.table.handle.column.aria-label' - ); - protected readonly rowAriaLabel = this.dotMessageService.get( - 'dot.block.editor.table.handle.row.aria-label' - ); - - protected readonly isVisible = computed(() => this.store.activeCell() !== null); - - private autoUpdateDispose: Array<() => void> = []; - - constructor() { - effect(() => { - const active = this.store.activeCell(); - this.teardownAutoUpdate(); - if (!active) return; - - for (const handle of this.collectHandles()) { - const refEl = pickReference(handle, active); - if (!refEl) continue; - this.autoUpdateDispose.push( - autoUpdate( - refEl, - handle.el, - () => this.applyPosition(handle.el, refEl, handle.placement), - { ancestorScroll: true, ancestorResize: true, elementResize: true } - ) - ); - } - }); - } - - ngOnDestroy(): void { - this.teardownAutoUpdate(); - } - - private collectHandles(): PositionedHandle[] { - const root = this.el.nativeElement as HTMLElement; - const buttons = root.querySelectorAll('.table-handle'); - if (buttons.length < 2) return []; - return [ - { el: buttons[0] as HTMLElement, placement: 'top' }, - { el: buttons[1] as HTMLElement, placement: 'left' } - ]; - } - - private applyPosition(el: HTMLElement, refEl: HTMLElement, placement: 'top' | 'left'): void { - void computePosition(refEl, el, { - placement: placement === 'top' ? 'top' : 'left', - strategy: 'fixed', - middleware: [shift({ padding: 4 })] - }).then(({ x, y }) => { - this.zone.run(() => { - el.style.position = 'fixed'; - el.style.left = `${x}px`; - el.style.top = `${y}px`; - }); - }); - } - - private teardownAutoUpdate(): void { - for (const dispose of this.autoUpdateDispose) dispose(); - this.autoUpdateDispose = []; - } - - // ── Popover openers ────────────────────────────────────────────────────── - - protected openColumn(anchor: HTMLElement): void { - const active = this.store.activeCell(); - if (!active) return; - this.popovers.openTableColumn(() => anchor.getBoundingClientRect(), { - cellPos: active.cellPos, - isHeader: active.isHeader, - headerScope: this.readScope(active) - }); - } - - protected openRow(anchor: HTMLElement): void { - const active = this.store.activeCell(); - if (!active) return; - this.popovers.openTableRow(() => anchor.getBoundingClientRect(), { - cellPos: active.cellPos - }); - } - - private readScope(active: ActiveCell): string { - const node = this.editor().state.doc.nodeAt(active.cellPos); - if (!node || node.type.name !== 'tableHeader') return ''; - return (node.attrs['scope'] as string | null) ?? ''; - } -} - -function pickReference(handle: PositionedHandle, active: ActiveCell): HTMLElement | null { - if (handle.placement === 'top' && handle.el.classList.contains('table-handle--column')) { - return active.columnHeadEl; - } - if (handle.placement === 'left' && handle.el.classList.contains('table-handle--row')) { - return active.rowHeadEl; - } - return null; -} diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/toolbar.component.html b/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/toolbar.component.html index 40ff2837367..a3f221ed841 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/toolbar.component.html +++ b/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/toolbar.component.html @@ -442,10 +442,9 @@ } @if (isAllowed('emoji')) {
` child element by - * the `renderHTML` override below. It's not contenteditable from the canvas — authors set - * it from the toolbar `table_edit` popover. - */ +import type { EditorPopoverService } from '../services/editor-popover.service'; + +// ── DotTable ─────────────────────────────────────────────────────────────────────── + const DotTable = Table.extend({ addAttributes() { return { @@ -22,8 +18,6 @@ const DotTable = Table.extend({ const captionEl = element.querySelector(':scope > caption'); return captionEl?.textContent?.trim() || null; }, - // Not a HTML attribute — emitted as a
child by the - // node-level renderHTML override below. renderHTML: () => ({}) }, ariaLabel: { @@ -45,40 +39,163 @@ const DotTable = Table.extend({ } } }; - }, - - /** - * Mirrors `@tiptap/extension-table`'s upstream `renderHTML` (preserves the `colgroup` - * generation that drives column resizing) and splices in a `` element when - * `attrs.caption` is set. HTML spec ordering: ` > ? > - * ` — caption MUST come first inside `
? >
`. - * - * Keep in sync with upstream if `@tiptap/extension-table` changes its renderer. - */ - renderHTML({ node, HTMLAttributes }) { - const { colgroup, tableWidth, tableMinWidth } = createColGroup( - node, - this.options.cellMinWidth - ); - const userStyles = HTMLAttributes['style']; - const style = - userStyles ?? (tableWidth ? `width: ${tableWidth}` : `min-width: ${tableMinWidth}`); - const caption = (node.attrs['caption'] as string | null)?.trim(); - const attrs = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { style }); - - const table = caption - ? (['table', attrs, ['caption', caption], colgroup, ['tbody', 0]] as const) - : (['table', attrs, colgroup, ['tbody', 0]] as const); - return this.options.renderWrapper ? ['div', { class: 'tableWrapper' }, table] : table; } }); +// ── DotTableCell / DotTableHeader (NodeViews with embedded handles) ──────────────── + +interface CellExtensionOptions { + HTMLAttributes: Record; + /** Injected by the editor component scope so click handlers can open the scoped popovers. */ + popovers: EditorPopoverService | null; + /** i18n labels for the handle buttons; supplied at extension-construction time. */ + columnAriaLabel: string; + rowAriaLabel: string; +} + +const CELL_ATTRS_TO_SYNC = ['colspan', 'rowspan', 'colwidth', 'align', 'scope'] as const; + /** - * Adds `scope` to `
` cells. The `TableScopeAutoAssign` ProseMirror plugin (registered - * below) fills this attribute in based on cell position; an author can still override the - * value to `colgroup` / `rowgroup` from the column popover — auto-assign skips non-null values. + * Renders a `` / `` with two child buttons (column handle + row handle) plus a + * content container. The buttons live inside the cell DOM, so positioning is pure CSS: + * + * - `--col` → `top: -12px; left: 50%; transform: translateX(-50%)` + * - `--row` → `top: 50%; left: -12px; transform: translateY(-50%)` + * + * The {@link TableActiveCellsPlugin} adds `.is-active-column` / `.is-active-row` classes to + * cells in the cursor's column / row; CSS first-child selectors then show the handle only on + * the first-row cell of the active column (and the first-cell of the active row). */ -const DotTableHeader = TableHeader.extend({ +function makeCellNodeViewFactory( + tag: 'td' | 'th', + options: CellExtensionOptions +): NodeViewRenderer { + return ({ node, getPos, HTMLAttributes }) => { + const popovers = options.popovers; + const cell = document.createElement(tag); + applyHTMLAttributes(cell, HTMLAttributes); + + const colHandle = makeHandleButton('column', 'more_horiz', options.columnAriaLabel); + const rowHandle = makeHandleButton('row', 'more_vert', options.rowAriaLabel); + + const content = document.createElement('div'); + content.className = 'dot-cell-content'; + + cell.append(colHandle, rowHandle, content); + + const resolveCellPos = (): number | null => { + const pos = typeof getPos === 'function' ? getPos() : null; + return typeof pos === 'number' ? pos : null; + }; + + if (popovers) { + colHandle.addEventListener('mousedown', (event) => { + event.preventDefault(); + event.stopPropagation(); + const pos = resolveCellPos(); + if (pos == null) return; + popovers.openTableColumn(() => colHandle.getBoundingClientRect(), { + cellPos: pos, + isHeader: tag === 'th', + headerScope: (currentNode.attrs['scope'] as string | null) ?? '' + }); + }); + rowHandle.addEventListener('mousedown', (event) => { + event.preventDefault(); + event.stopPropagation(); + const pos = resolveCellPos(); + if (pos == null) return; + popovers.openTableRow(() => rowHandle.getBoundingClientRect(), { + cellPos: pos + }); + }); + } + + // The closure captures `node` at creation time. We update this reference in + // `update()` so the click handlers always see the latest attrs (e.g. scope). + let currentNode: PMNode = node; + + return { + dom: cell, + contentDOM: content, + update: (newNode) => { + if (newNode.type.name !== node.type.name) return false; + currentNode = newNode; + // Sync the known attrs onto the cell element. We can't just call + // `applyHTMLAttributes` again because the new HTMLAttributes aren't + // passed to `update` — we resolve from `newNode.attrs` directly. + for (const attr of CELL_ATTRS_TO_SYNC) { + const value = newNode.attrs[attr]; + if (value == null || value === '') cell.removeAttribute(attr); + else cell.setAttribute(attr, String(value)); + } + return true; + }, + // The handle buttons + their icon spans are NodeView-owned DOM. Prevent + // ProseMirror from re-parsing them on every mutation inside. + ignoreMutation: (mutation) => { + const target = mutation.target as Element | null; + if (!target) return false; + if (target instanceof HTMLElement && target.closest('.dot-cell-handle')) { + return true; + } + return false; + } + }; + }; +} + +function makeHandleButton( + kind: 'column' | 'row', + icon: string, + ariaLabel: string +): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.className = `dot-cell-handle dot-cell-handle--${kind === 'column' ? 'col' : 'row'}`; + button.setAttribute('contenteditable', 'false'); + button.setAttribute('tabindex', '-1'); + button.setAttribute('aria-label', ariaLabel); + button.dataset['testid'] = `table-${kind}-handle`; + + const iconSpan = document.createElement('span'); + iconSpan.className = 'material-symbols-outlined'; + iconSpan.setAttribute('aria-hidden', 'true'); + iconSpan.textContent = icon; + button.appendChild(iconSpan); + return button; +} + +function applyHTMLAttributes(el: HTMLElement, attrs: Record): void { + for (const [key, value] of Object.entries(attrs)) { + if (value == null || value === '' || value === false) continue; + el.setAttribute(key, String(value)); + } +} + +const DotTableCell = TableCell.extend({ + addOptions() { + return { + ...this.parent?.(), + popovers: null, + columnAriaLabel: 'Column actions', + rowAriaLabel: 'Row actions' + }; + }, + addNodeView() { + return makeCellNodeViewFactory('td', this.options); + } +}); + +const DotTableHeader = TableHeader.extend({ + addOptions() { + return { + ...this.parent?.(), + popovers: null, + columnAriaLabel: 'Column actions', + rowAriaLabel: 'Row actions' + }; + }, addAttributes() { return { ...this.parent?.(), @@ -92,34 +209,42 @@ const DotTableHeader = TableHeader.extend({ } } }; + }, + addNodeView() { + return makeCellNodeViewFactory('th', this.options); } }); +// ── Bundle ───────────────────────────────────────────────────────────────────────── + interface DotTableKitOptions { - /** Forwarded to the underlying `Table` config (e.g. `{ resizable: true }`). */ table?: Parameters[0]; + /** Cell + header NodeView options — used to inject the popover service + aria labels. */ + cell?: Partial; + header?: Partial; } /** - * Returns the full set of table-related TipTap extensions: + * Returns the full set of table-related TipTap extensions. The cell + header NodeViews + * each receive an {@link EditorPopoverService} via options so their click handlers can open + * the column / row popovers without going through Angular DI. * - * - `DotTable` — Table extended with `caption` + `ariaLabel` + `ariaLabelledby` attributes - * (overrides `TableKit.table`). Caption is rendered as a `
` child element. - * - `DotTableHeader` — TableHeader extended with `scope` (overrides `TableKit.tableHeader`). - * - `TableCell` + `TableRow` — supplied unchanged by `TableKit`. - * - `TableScopeAutoAssign` — fills `scope` on header cells based on their position. + * - `DotTable` — adds caption + aria-label + aria-labelledby attributes. + * - `DotTableCell` / `DotTableHeader` — NodeView renders handle buttons inside the cell. + * - `TableCell` + `TableRow` come from `TableKit` (cell is overridden here; row stays default). + * - `TableScopeAutoAssign` — fills `scope` on header cells based on position. */ export function createDotTableExtensions(options: DotTableKitOptions = {}) { return [ - // The kit still provides TableRow + TableCell + ProseMirror table editing plugins. - // We disable its table + tableHeader entries because we provide extended versions - // below — leaving them enabled would register two nodes with the same name. + // We provide custom Table, TableCell and TableHeader; disable the kit's versions. TableKit.configure({ table: false, + tableCell: false, tableHeader: false }), DotTable.configure(options.table ?? {}), - DotTableHeader, + DotTableCell.configure(options.cell ?? {}), + DotTableHeader.configure(options.header ?? {}), TableScopeAutoAssign ]; } diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/table-selection.plugin.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/table-selection.plugin.ts deleted file mode 100644 index 4947853553c..00000000000 --- a/core-web/libs/new-block-editor/src/lib/editor/extensions/table-selection.plugin.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { Extension } from '@tiptap/core'; -import { Node as PMNode, ResolvedPos } from '@tiptap/pm/model'; -import { Plugin, PluginKey } from '@tiptap/pm/state'; -import { TableMap } from '@tiptap/pm/tables'; -import { EditorView } from '@tiptap/pm/view'; - -import type { ActiveCell, TableHandlesStore } from '../services/table-handles.store'; - -const PLUGIN_KEY = new PluginKey('tableSelectionHandles'); - -interface TableSelectionOptions { - store: TableHandlesStore; -} - -/** - * Selection-driven tracker for the floating column / row handles. - * - * Phase 3 replacement for the noisy hover-based tracker. Hooks `Plugin.view(view).update` - * — ProseMirror calls this after every state change (selection move or doc edit) — and - * pushes the cell containing the cursor to {@link TableHandlesStore}. When the cursor - * leaves any table, the store is cleared to `null`. - * - * Short-circuits when the active cell hasn't changed (same `cellPos`) so signal subscribers - * don't churn on every keystroke inside a cell. - */ -export const TableSelectionPlugin = Extension.create({ - name: 'tableSelectionHandles', - - addOptions() { - return { - store: null as unknown as TableHandlesStore - }; - }, - - addProseMirrorPlugins() { - const store = this.options.store; - if (!store) return []; - - return [ - new Plugin({ - key: PLUGIN_KEY, - view: (view) => { - // Initial resolve — the editor may already be inside a table on mount. - const initial = resolveActiveCell(view); - if (initial) store.setActiveCell(initial); - - return { - update: (updatedView) => { - const next = resolveActiveCell(updatedView); - const current = store.activeCell(); - if (next?.cellPos === current?.cellPos) return; - store.setActiveCell(next); - }, - destroy: () => { - store.reset(); - } - }; - } - }) - ]; - } -}); - -function resolveActiveCell(view: EditorView): ActiveCell | null { - const $pos = view.state.selection.$from; - const tableInfo = findTableAt($pos); - if (!tableInfo) return null; - - const { tableNode, tablePos, cellPos, rowPos } = tableInfo; - const map = TableMap.get(tableNode); - const cellOffset = cellPos - tablePos - 1; - const cellIndex = map.map.indexOf(cellOffset); - if (cellIndex < 0) return null; - - const colIndex = cellIndex % map.width; - const rowIndex = Math.floor(cellIndex / map.width); - - const cellDom = view.nodeDOM(cellPos); - if (!(cellDom instanceof HTMLElement)) return null; - const tableEl = cellDom.closest('table'); - if (!(tableEl instanceof HTMLElement)) return null; - - return { - cellPos, - rowPos, - tablePos, - colIndex, - rowIndex, - isHeader: tableNode.nodeAt(cellOffset)?.type.name === 'tableHeader', - cellEl: cellDom, - columnHeadEl: findColumnHeadEl(tableEl, colIndex) ?? cellDom, - rowHeadEl: findRowHeadEl(tableEl, rowIndex) ?? cellDom, - tableEl - }; -} - -interface TableInfo { - tableNode: PMNode; - tablePos: number; - rowPos: number; - cellPos: number; -} - -/** - * Walks up from a resolved position to find the surrounding table + row + cell. Returns - * `null` when the position is not inside a table cell. Used instead of `cellAround` so we - * handle every selection type uniformly (TextSelection, NodeSelection, CellSelection — the - * resolved `$from` always has a depth path we can traverse). - */ -function findTableAt($pos: ResolvedPos): TableInfo | null { - for (let depth = $pos.depth; depth > 0; depth--) { - const node = $pos.node(depth); - if (node.type.name === 'table') { - const tablePos = $pos.before(depth); - const rowDepth = depth + 1; - const cellDepth = depth + 2; - if ($pos.depth < cellDepth) return null; - const rowPos = $pos.before(rowDepth); - const cellPos = $pos.before(cellDepth); - return { tableNode: node, tablePos, rowPos, cellPos }; - } - } - return null; -} - -function findColumnHeadEl(tableEl: HTMLElement, colIndex: number): HTMLElement | null { - const firstRow = tableEl.querySelector(':scope > tbody > tr') as HTMLElement | null; - if (!firstRow) return null; - return (firstRow.children.item(colIndex) as HTMLElement | null) ?? null; -} - -function findRowHeadEl(tableEl: HTMLElement, rowIndex: number): HTMLElement | null { - const rows = tableEl.querySelectorAll(':scope > tbody > tr'); - const row = rows.item(rowIndex) as HTMLElement | null; - if (!row) return null; - return (row.children.item(0) as HTMLElement | null) ?? null; -} diff --git a/core-web/libs/new-block-editor/src/lib/editor/services/table-handles.store.ts b/core-web/libs/new-block-editor/src/lib/editor/services/table-handles.store.ts deleted file mode 100644 index 27d068e693b..00000000000 --- a/core-web/libs/new-block-editor/src/lib/editor/services/table-handles.store.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Injectable, NgZone, computed, inject, signal } from '@angular/core'; - -/** - * Snapshot of the cell currently containing the editor cursor. Pushed by - * {@link TableSelectionPlugin} on every relevant `view.update`; consumed by the floating - * column / row handles to compute their anchor positions. - */ -export interface ActiveCell { - /** Document position of the cell node. */ - readonly cellPos: number; - /** Document position of the row that owns the cell. */ - readonly rowPos: number; - /** Document position of the table that owns the row. */ - readonly tablePos: number; - /** Zero-indexed column position within the table. */ - readonly colIndex: number; - /** Zero-indexed row position within the table. */ - readonly rowIndex: number; - /** True when the cell node is `tableHeader` (not `tableCell`). */ - readonly isHeader: boolean; - /** The cell's own DOM element. */ - readonly cellEl: HTMLElement; - /** First-row cell of this column (column handle anchors here). */ - readonly columnHeadEl: HTMLElement; - /** First cell of this row (row handle anchors here). */ - readonly rowHeadEl: HTMLElement; - /** The `` element this cell belongs to. */ - readonly tableEl: HTMLElement; -} - -/** - * Tracks the cell the editor cursor is currently inside. Updated by the selection plugin - * whenever a `view.update` resolves a new cell; cleared to `null` when the cursor leaves - * any table. - * - * Selection-driven (Phase 3) — replaces the hover-driven model from Phase 2. There is no - * grace period or lock state: the handle buttons themselves use `mousedown.preventDefault` - * to keep the cursor inside the cell, so the store's value naturally stays put while the - * user interacts with the floating popovers. - * - * Provided at the editor component scope so each editor instance has its own state. - */ -@Injectable() -export class TableHandlesStore { - private readonly zone = inject(NgZone); - - private readonly _activeCell = signal(null); - - /** Current cell holding the cursor, or `null` when the cursor is outside any table. */ - readonly activeCell = this._activeCell.asReadonly(); - - /** True when the handles should be rendered. */ - readonly isVisible = computed(() => this._activeCell() !== null); - - setActiveCell(cell: ActiveCell | null): void { - this.zone.run(() => this._activeCell.set(cell)); - } - - reset(): void { - this._activeCell.set(null); - } -} From 6a6bfd7c4590b7d09d12aa04fefe2cf8ea9a4fb2 Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Mon, 18 May 2026 16:36:14 -0400 Subject: [PATCH 3/4] chore: add `todo.md` --- core-web/libs/new-block-editor/todo.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 core-web/libs/new-block-editor/todo.md diff --git a/core-web/libs/new-block-editor/todo.md b/core-web/libs/new-block-editor/todo.md new file mode 100644 index 00000000000..cf785d88c40 --- /dev/null +++ b/core-web/libs/new-block-editor/todo.md @@ -0,0 +1,4 @@ +# Table Accessibility (#35720) — Follow-ups + +- [ ] **Update the VTL file** to render the new table attributes in the table node — `caption`, `aria-label`, `aria-labelledby` (on `
`), and `scope` (on `
`) — so server-rendered (non-headless) pages match the editor output and meet WCAG 1.3.1 / 4.1.2. +- [ ] **Add a merge-cells button** to the table handles to support merge / split actions. Merge / split / delete-table were dropped from the Phase 3 a11y popover; they still need a home for parity with the legacy table-editing surface. From 9da971ace93ff4e7d6d697ae3c404f1833927b38 Mon Sep 17 00:00:00 2001 From: rjvelazco Date: Wed, 20 May 2026 17:01:06 -0400 Subject: [PATCH 4/4] style(table-column-popover): update styling and structure of table column scope popover --- .../table-column-popover.component.ts | 47 ++++++++++++++----- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-column-popover.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-column-popover.component.ts index 8605f22dfdd..d7df5816eaa 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-column-popover.component.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-column-popover.component.ts @@ -39,26 +39,37 @@ import { EditorPopoverComponent } from '../editor-popover/editor-popover.compone [attr.aria-label]="'dot.block.editor.table.handle.column.aria-label' | dm" class="w-56 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg py-1"> - - @if (showScope()) { -
-