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
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
352 lines
11 KiB
TypeScript
352 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|