dorfteich/apps/web/src/editor/Toolbar.tsx
Claude Fable 5 3bf9363c34
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 7m18s
CI / Build container images (pull_request) Successful in 4m41s
CI / Auth e2e pack (pull_request) Successful in 10m4s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Successful in 33s
CI / Lint, typecheck, test (push) Has been cancelled
Merge and split table cells (#337)
prosemirror-tables already ships mergeCells/splitCell and the schema
(tableNodes) already carries colspan/rowspan -- only the controls were
missing. Adds the two commands, toolbar buttons whose enabled state
follows the selection (merge needs a multi-cell selection, split a
merged cell), and de+en labels.

Both render paths now carry the spans: docToHtml emits colspan/rowspan
(read mode, exports via the HTML path), and the markdown serializer pads
a colspan with empty cells so every row keeps the table's column count
-- rowspan stays lossy there, GFM cannot express it.

e2e drives merge and split through the toolbar; the cell selection is
made per Shift+Click because a keypress in the same tick as the
preceding click races the editor's post-click rendering (keyboard cell
selection itself works, verified interactively with a settled editor).

Closes #337

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aoPvnakfBP28nAfijgUY9
2026-08-15 21:32:14 +02:00

352 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Editor } from '@tiptap/core';
import { useEditorState } from '@tiptap/react';
import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { PluginBlockOption, SectionStyleOption } from '../plugins/use-pond-plugins';
import { LinkMenu } from './LinkMenu';
import { PluginBlockMenu } from './PluginBlockMenu';
import { SectionStyleMenu } from './SectionStyleMenu';
interface ToolbarProps {
editor: Editor;
/** Section styles offered by the pond's active plugins (issue #75); the
* section group is omitted while empty. */
sectionStyles?: SectionStyleOption[];
/** Block types offered by the pond's active code plugins (issue #76); the
* insert group is omitted while empty. */
pluginBlocks?: PluginBlockOption[];
}
function ToolbarButton({
label,
active,
disabled,
onClick,
children,
}: {
label: string;
active?: boolean;
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
}): React.JSX.Element {
return (
<button
type="button"
className={active ? 'toolbar-button toolbar-button--active' : 'toolbar-button'}
title={label}
aria-label={label}
aria-pressed={active ?? false}
disabled={disabled}
// Toolbar clicks must not steal focus/selection from the editor.
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
>
{children}
</button>
);
}
/** File-picker path into the same async upload as paste/drop (issue #28) —
* a hidden native file input triggered by a normal toolbar button, since
* that is the only way to open the OS file dialog from a click handler. */
function ImageInsertButton({
editor,
label,
}: {
editor: Editor;
label: string;
}): React.JSX.Element {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<ToolbarButton label={label} onClick={() => inputRef.current?.click()}>
🖼
</ToolbarButton>
<input
ref={inputRef}
type="file"
accept="image/*"
hidden
onChange={(event) => {
const file = event.target.files?.[0];
if (file) editor.chain().focus().insertImageFile(file).run();
event.target.value = '';
}}
/>
</>
);
}
/** Arrow-key navigation between toolbar controls (#164, role="toolbar"
* convention). Native selects keep their own arrow-key handling. */
function onToolbarArrowKey(event: React.KeyboardEvent<HTMLDivElement>): void {
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return;
const target = event.target as HTMLElement;
if (target.tagName === 'SELECT' || target.tagName === 'INPUT') return;
const controls = [
...event.currentTarget.querySelectorAll<HTMLElement>('button:not([disabled]), select'),
];
const index = controls.indexOf(target);
if (index === -1) return;
event.preventDefault();
const step = event.key === 'ArrowRight' ? 1 : -1;
controls[(index + step + controls.length) % controls.length]?.focus();
}
/** Keyboard-accessible toolbar for the page editor (issue #25). Table row/
* column controls stay visible but disabled outside a table, so the
* toolbar's layout and tab order never shift while typing. */
export function Toolbar({
editor,
sectionStyles = [],
pluginBlocks = [],
}: ToolbarProps): React.JSX.Element {
const { t } = useTranslation('editor');
const state = useEditorState({
editor,
selector: ({ editor: e }) => ({
paragraph: e.isActive('paragraph'),
heading1: e.isActive('heading', { level: 1 }),
heading2: e.isActive('heading', { level: 2 }),
heading3: e.isActive('heading', { level: 3 }),
heading4: e.isActive('heading', { level: 4 }),
bold: e.isActive('bold'),
italic: e.isActive('italic'),
code: e.isActive('code'),
strikethrough: e.isActive('strikethrough'),
bulletList: e.isActive('bullet_list'),
orderedList: e.isActive('ordered_list'),
taskList: e.isActive('task_list'),
blockquote: e.isActive('blockquote'),
codeBlock: e.isActive('code_block'),
canAddRow: e.can().addRowAfter(),
canDeleteRow: e.can().deleteRow(),
canAddColumn: e.can().addColumnAfter(),
canDeleteColumn: e.can().deleteColumn(),
canDeleteTable: e.can().deleteTable(),
canMergeCells: e.can().mergeCells(),
canSplitCell: e.can().splitCell(),
canToggleHeaderRow: e.can().toggleHeaderRow(),
canUndo: e.can().undo(),
canRedo: e.can().redo(),
}),
});
return (
<div
className="editor-toolbar"
role="toolbar"
aria-label={t('toolbar.label')}
onKeyDown={onToolbarArrowKey}
>
<div className="editor-toolbar__group">
<ToolbarButton
label={t('toolbar.paragraph')}
active={state.paragraph}
onClick={() => editor.chain().focus().setParagraph().run()}
>
P
</ToolbarButton>
{([1, 2, 3, 4] as const).map((level) => (
<ToolbarButton
key={level}
label={t(`toolbar.heading${level}` as const)}
active={state[`heading${level}` as const]}
onClick={() => editor.chain().focus().toggleHeading(level).run()}
>
H{level}
</ToolbarButton>
))}
</div>
<div className="editor-toolbar__group">
<ToolbarButton
label={t('toolbar.bold')}
active={state.bold}
onClick={() => editor.chain().focus().toggleBold().run()}
>
<strong>B</strong>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.italic')}
active={state.italic}
onClick={() => editor.chain().focus().toggleItalic().run()}
>
<em>I</em>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.code')}
active={state.code}
onClick={() => editor.chain().focus().toggleCode().run()}
>
{'</>'}
</ToolbarButton>
<ToolbarButton
label={t('toolbar.strikethrough')}
active={state.strikethrough}
onClick={() => editor.chain().focus().toggleStrikethrough().run()}
>
<s>S</s>
</ToolbarButton>
<LinkMenu editor={editor} />
</div>
<div className="editor-toolbar__group">
<ToolbarButton
label={t('toolbar.bulletList')}
active={state.bulletList}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.orderedList')}
active={state.orderedList}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
1.
</ToolbarButton>
<ToolbarButton
label={t('toolbar.taskList')}
active={state.taskList}
onClick={() => editor.chain().focus().toggleTaskList().run()}
>
</ToolbarButton>
</div>
<div className="editor-toolbar__group">
<ToolbarButton
label={t('toolbar.blockquote')}
active={state.blockquote}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.codeBlock')}
active={state.codeBlock}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
>
{'{ }'}
</ToolbarButton>
<ToolbarButton
label={t('toolbar.horizontalRule')}
onClick={() => editor.chain().focus().setHorizontalRule().run()}
>
</ToolbarButton>
<ImageInsertButton editor={editor} label={t('toolbar.image')} />
</div>
<SectionStyleMenu editor={editor} options={sectionStyles} />
<PluginBlockMenu editor={editor} options={pluginBlocks} />
<div className="editor-toolbar__group">
<ToolbarButton
label={t('toolbar.table.insert')}
onClick={() => editor.chain().focus().insertTable().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.addColumnBefore')}
disabled={!state.canAddColumn}
onClick={() => editor.chain().focus().addColumnBefore().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.addColumnAfter')}
disabled={!state.canAddColumn}
onClick={() => editor.chain().focus().addColumnAfter().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.deleteColumn')}
disabled={!state.canDeleteColumn}
onClick={() => editor.chain().focus().deleteColumn().run()}
>
{/* Axis stripes + the × delete marker (already established by
deleteTable's ⊠): the earlier ⊟↕/⊟↔ double arrows read as
"resize/expand", not "delete" (issue #336). */}
×
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.addRowBefore')}
disabled={!state.canAddRow}
onClick={() => editor.chain().focus().addRowBefore().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.addRowAfter')}
disabled={!state.canAddRow}
onClick={() => editor.chain().focus().addRowAfter().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.deleteRow')}
disabled={!state.canDeleteRow}
onClick={() => editor.chain().focus().deleteRow().run()}
>
×
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.mergeCells')}
disabled={!state.canMergeCells}
onClick={() => editor.chain().focus().mergeCells().run()}
>
{/* Arrows collapsing onto / leaving a cell border: merge removes
the border between selected cells, split restores it. */}
|
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.splitCell')}
disabled={!state.canSplitCell}
onClick={() => editor.chain().focus().splitCell().run()}
>
|
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.toggleHeaderRow')}
disabled={!state.canToggleHeaderRow}
onClick={() => editor.chain().focus().toggleHeaderRow().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.table.deleteTable')}
disabled={!state.canDeleteTable}
onClick={() => editor.chain().focus().deleteTable().run()}
>
</ToolbarButton>
</div>
<div className="editor-toolbar__group">
<ToolbarButton
label={t('toolbar.undo')}
disabled={!state.canUndo}
onClick={() => editor.chain().focus().undo().run()}
>
</ToolbarButton>
<ToolbarButton
label={t('toolbar.redo')}
disabled={!state.canRedo}
onClick={() => editor.chain().focus().redo().run()}
>
</ToolbarButton>
</div>
</div>
);
}