Die Editorfläche bekommt einen lokalisierten zugänglichen Namen und ist im Lesemodus role=document statt eines unbenannten Textfelds (setOptions im selben Layout-Effekt wie setEditable). Eingeklappte Sidebar zusätzlich inert (aria-hidden allein ließ fokussierbare Kinder im Tab-Weg). Die li-Zwischenknoten der Listboxen (Wikilink-/Mention-Autocomplete, Suchergebnisse) sind role=presentation, damit listbox→option wieder eine gültige Eltern-Kind-Beziehung ist. Toolbar: Pfeiltasten-Navigation über die Controls (native Selects behalten ihre Pfeiltasten) und ein sprechendes Toolbar-Label statt des Absatz-Buttons-Labels. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
331 lines
10 KiB
TypeScript
331 lines
10 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(),
|
||
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()}
|
||
>
|
||
⊟↕
|
||
</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.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>
|
||
);
|
||
}
|