mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-28 14:06:36 +00:00
feat(markdown-editor): shadcn toolbar with link/image popovers and table controls
Rework the editor toolbar into a shadcn-styled family of small modules: heading and list dropdowns, an adaptive table menu (GFM-safe: no merge/split), inline popovers for links and images with inline validation + URL normalization (replacing window.prompt), click-to-edit link/image handles anchored to the node, a reset-formatting action, roving-tabindex a11y, and tooltips. URL/image sources are normalized to absolute https and gated by a protocol allowlist. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
30b22f9c17
commit
36608a9dd0
@@ -1,6 +1,8 @@
|
||||
// Deliberately does NOT re-export the heavy MarkdownEditor value — that would statically pull the tiptap chunk
|
||||
// into any route importing a light util from here. Consume the mode-switching MarkdownEditorField instead (it
|
||||
// owns the lazy() boundary, so importing it is chunk-free until rich mode renders).
|
||||
// For the raw rich editor standalone, import './markdown-editor' by path — that eagerly bundles tiptap into the
|
||||
// route, so wrap it in lazy(() => import('./markdown-editor')) yourself if you need the chunk deferred.
|
||||
export { MarkdownEditorField } from './markdown-editor-field';
|
||||
export type { MarkdownEditorFieldHandle } from './markdown-editor-field';
|
||||
export { findVariableUseRanges, VARIABLE_RE, variableUseRegex } from './markdown-editor-variable-syntax';
|
||||
|
||||
@@ -98,10 +98,12 @@ const TunedStarterKit = StarterKit.extend({
|
||||
// • link autolink/linkOnPaste: true — a bare URL/email becomes a link on load, paste, AND typing, kept
|
||||
// symmetric with the marked layer (which no longer neutralises autolink/url). Do NOT set false: it
|
||||
// diverges typing from load and re-freezes bare URLs as text.
|
||||
// • link openOnClick: false — a click seats the caret in the link instead of navigating away, so LinkHandle
|
||||
// (markdown-editor-link-handle.tsx) can show the edit popover; opening still works via that popover's button.
|
||||
export const createMarkdownExtensions = (placeholder?: string) => [
|
||||
TunedStarterKit.configure({
|
||||
codeBlock: { HTMLAttributes: { class: 'hljs' } },
|
||||
link: { autolink: true, linkOnPaste: true },
|
||||
link: { autolink: true, linkOnPaste: true, openOnClick: false },
|
||||
underline: false,
|
||||
}),
|
||||
TunedTable.configure({ resizable: true }),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { Editor } from '@tiptap/react';
|
||||
|
||||
import { Check, Trash2 } from 'lucide-react';
|
||||
import { useId, useState } from 'react';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
import { normalizeImageSrc } from './markdown-editor-toolbar-url';
|
||||
|
||||
interface ImageEditFormProps {
|
||||
// Focus the URL input on mount. True when the user explicitly opened the form (toolbar button); false for the
|
||||
// on-image popover, which appears when an image is selected and must not steal focus.
|
||||
autoFocus?: boolean;
|
||||
editor: Editor;
|
||||
initialAlt: string;
|
||||
initialSrc: string;
|
||||
// true = editing the selected image (updateAttributes + Remove); false = inserting a new one (setImage).
|
||||
isEditing: boolean;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
// Shared body of the image editor — the same src field + validation + alt used by the toolbar Insert-image popover
|
||||
// AND the on-image popover (markdown-editor-image-handle.tsx). Seeds its own state from the initial props on mount,
|
||||
// so consumers give it a fresh `key` per editing session.
|
||||
export function ImageEditForm({
|
||||
autoFocus = true,
|
||||
editor,
|
||||
initialAlt,
|
||||
initialSrc,
|
||||
isEditing,
|
||||
onDone,
|
||||
}: ImageEditFormProps) {
|
||||
const [src, setSrc] = useState(initialSrc);
|
||||
const [alt, setAlt] = useState(initialAlt);
|
||||
const srcId = useId();
|
||||
const altId = useId();
|
||||
const errorId = useId();
|
||||
|
||||
const normalizedSrc = normalizeImageSrc(src);
|
||||
const isInvalid = src !== '' && normalizedSrc === null;
|
||||
|
||||
const apply = () => {
|
||||
if (!normalizedSrc) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes('image', { alt: alt || null, src: normalizedSrc })
|
||||
.run();
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({ alt: alt || undefined, src: normalizedSrc })
|
||||
.run();
|
||||
}
|
||||
|
||||
onDone();
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
editor.chain().focus().deleteSelection().run();
|
||||
onDone();
|
||||
};
|
||||
|
||||
const applyOnEnter = (event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
apply();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={srcId}>Image URL</Label>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
aria-describedby={isInvalid ? errorId : undefined}
|
||||
aria-invalid={isInvalid}
|
||||
autoFocus={autoFocus}
|
||||
id={srcId}
|
||||
onChange={(event) => setSrc(event.target.value)}
|
||||
onKeyDown={applyOnEnter}
|
||||
placeholder="https://example.com/image.png"
|
||||
type="url"
|
||||
value={src}
|
||||
/>
|
||||
<InputGroupAddon
|
||||
align="inline-end"
|
||||
className="gap-0"
|
||||
>
|
||||
<InputGroupButton
|
||||
aria-label={isEditing ? 'Apply image' : 'Insert image'}
|
||||
disabled={src === '' || isInvalid}
|
||||
onClick={apply}
|
||||
size="icon-xs"
|
||||
>
|
||||
<Check />
|
||||
</InputGroupButton>
|
||||
{isEditing ? (
|
||||
<InputGroupButton
|
||||
aria-label="Remove image"
|
||||
onClick={remove}
|
||||
size="icon-xs"
|
||||
>
|
||||
<Trash2 />
|
||||
</InputGroupButton>
|
||||
) : null}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={altId}>Alt text (optional)</Label>
|
||||
<Input
|
||||
id={altId}
|
||||
onChange={(event) => setAlt(event.target.value)}
|
||||
onKeyDown={applyOnEnter}
|
||||
placeholder="Describe the image"
|
||||
value={alt}
|
||||
/>
|
||||
</div>
|
||||
{isInvalid ? (
|
||||
<p
|
||||
className="text-destructive text-xs"
|
||||
id={errorId}
|
||||
role="alert"
|
||||
>
|
||||
Only http(s) or base64 raster image URLs are allowed.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Editor } from '@tiptap/react';
|
||||
|
||||
import { posToDOMRect } from '@tiptap/core';
|
||||
import { NodeSelection } from '@tiptap/pm/state';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
|
||||
|
||||
import { ImageEditForm } from './markdown-editor-image-edit-form';
|
||||
|
||||
interface ImageTarget {
|
||||
alt: string;
|
||||
key: number;
|
||||
rect: { height: number; left: number; top: number; width: number };
|
||||
src: string;
|
||||
}
|
||||
|
||||
// Shows the shared image editor anchored to the selected image. Clicking an image makes it a NodeSelection (it
|
||||
// never navigates), so `selection instanceof NodeSelection && node.type.name === 'image'` detects "an image is
|
||||
// selected". Overlay-only — it never mutates the doc until a form action runs (like TableHandles / LinkHandle).
|
||||
export function ImageHandle({ editor }: { editor: Editor }) {
|
||||
const { close, target } = useImageHandle(editor);
|
||||
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { alt, key, rect, src } = target;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
close();
|
||||
}
|
||||
}}
|
||||
open
|
||||
>
|
||||
<PopoverAnchor asChild>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed"
|
||||
style={{ height: rect.height, left: rect.left, top: rect.top, width: rect.width }}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-80"
|
||||
data-image-handle=""
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
editor.commands.focus();
|
||||
}}
|
||||
onOpenAutoFocus={(event) => {
|
||||
// The popover appears when an image is selected — keep focus in the doc so it doesn't steal it;
|
||||
// the user clicks into the URL field only to actually edit.
|
||||
event.preventDefault();
|
||||
}}
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
>
|
||||
<ImageEditForm
|
||||
autoFocus={false}
|
||||
editor={editor}
|
||||
initialAlt={alt}
|
||||
initialSrc={src}
|
||||
isEditing
|
||||
key={key}
|
||||
onDone={close}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function useImageHandle(editor: Editor) {
|
||||
const [target, setTarget] = useState<ImageTarget | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const { selection } = editor.state;
|
||||
|
||||
if (!(selection instanceof NodeSelection) || selection.node.type.name !== 'image') {
|
||||
setTarget(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
const { alt, src } = selection.node.attrs;
|
||||
|
||||
setTarget({ alt: (alt as string) ?? '', key: selection.from, rect, src: (src as string) ?? '' });
|
||||
};
|
||||
|
||||
const clear = () => setTarget(null);
|
||||
|
||||
editor.on('selectionUpdate', update);
|
||||
|
||||
// Fixed-positioned anchor goes stale on scroll/resize — drop it (it reappears on the next selection).
|
||||
const scrollParent = editor.view.dom.closest('.tiptap-content') ?? window;
|
||||
|
||||
scrollParent.addEventListener('scroll', clear, { passive: true });
|
||||
window.addEventListener('resize', clear);
|
||||
|
||||
return () => {
|
||||
editor.off('selectionUpdate', update);
|
||||
scrollParent.removeEventListener('scroll', clear);
|
||||
window.removeEventListener('resize', clear);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return { close: () => setTarget(null), target };
|
||||
}
|
||||
@@ -1,20 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isSafeImageSrc, isSafeUrl } from './markdown-editor-toolbar';
|
||||
import { normalizeImageSrc, normalizeLinkUrl } from './markdown-editor-toolbar-url';
|
||||
|
||||
// The Image extension stores whatever src it is handed; isSafeImageSrc is the allowlist that blocks dangerous
|
||||
// PROTOCOLS (javascript:, data:text/html, vbscript:, file:) before a src is saved. A bare relative string
|
||||
// resolves against the page origin (http/https) and is intentionally allowed — the guard validates the
|
||||
// protocol, not that the URL is absolute. data: URLs are limited to base64 raster formats: svg+xml is the one
|
||||
// image type that can carry script.
|
||||
describe('isSafeImageSrc — image-src protocol allowlist', () => {
|
||||
// normalizeImageSrc makes a scheme-less src absolute (https://) and validates the protocol before it is saved:
|
||||
// http(s) and base64 raster data: URLs pass through; data:image/svg+xml is rejected (SVG can carry script), as
|
||||
// are data:text/html, application/*, javascript:, vbscript:, and malformed input.
|
||||
describe('normalizeImageSrc — prepend https to scheme-less src, validate protocol', () => {
|
||||
it.each([
|
||||
'http://example.com/a.png',
|
||||
'https://example.com/a.png',
|
||||
'data:image/png;base64,AAAA',
|
||||
'data:image/webp;base64,AAAA',
|
||||
])('allows %s', (url) => {
|
||||
expect(isSafeImageSrc(url)).toBe(true);
|
||||
['example.com/a.png', 'https://example.com/a.png'],
|
||||
['//cdn.example.com/a.png', 'https://cdn.example.com/a.png'],
|
||||
[' example.com/a.png ', 'https://example.com/a.png'],
|
||||
['http://example.com/a.png', 'http://example.com/a.png'],
|
||||
['https://example.com/a.png?w=1', 'https://example.com/a.png?w=1'],
|
||||
['data:image/png;base64,AAAA', 'data:image/png;base64,AAAA'],
|
||||
['data:image/webp;base64,AAAA', 'data:image/webp;base64,AAAA'],
|
||||
])('normalizes %s → %s', (input, expected) => {
|
||||
expect(normalizeImageSrc(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -24,33 +25,42 @@ describe('isSafeImageSrc — image-src protocol allowlist', () => {
|
||||
'data:image/svg+xml;utf8,<svg onload=alert(1)/>',
|
||||
'data:image/svg+xml;base64,AAAA',
|
||||
'vbscript:msgbox(1)',
|
||||
'file:///etc/passwd',
|
||||
'http://', // malformed → URL constructor throws → rejected via the catch
|
||||
])('rejects %s', (url) => {
|
||||
expect(isSafeImageSrc(url)).toBe(false);
|
||||
'http://', // malformed → no host
|
||||
'',
|
||||
])('rejects %s', (input) => {
|
||||
expect(normalizeImageSrc(input)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSafeUrl — link-href protocol allowlist', () => {
|
||||
// A manually-typed link must be made absolute — a scheme-less input like "example.com" would otherwise persist as
|
||||
// a relative href the browser resolves against the current origin. normalizeLinkUrl prepends https:// unless the
|
||||
// input already carries an allowed scheme, validates the protocol with no base URL, and returns null for
|
||||
// unsafe/malformed input. Already-schemed values pass through verbatim (case + query chars preserved).
|
||||
describe('normalizeLinkUrl — prepend https to scheme-less input, validate protocol', () => {
|
||||
it.each([
|
||||
'https://example.com/path?a=1|2',
|
||||
'http://example.com',
|
||||
'mailto:a@b.com',
|
||||
'tel:+123',
|
||||
'/relative/path',
|
||||
'#anchor',
|
||||
'./sibling',
|
||||
])('allows %s', (url) => {
|
||||
expect(isSafeUrl(url)).toBe(true);
|
||||
['example.com', 'https://example.com'],
|
||||
['www.example.com', 'https://www.example.com'],
|
||||
['example.com:8080', 'https://example.com:8080'],
|
||||
['localhost:3000', 'https://localhost:3000'],
|
||||
['//evil.com', 'https://evil.com'],
|
||||
[' example.com ', 'https://example.com'],
|
||||
['http://example.com', 'http://example.com'],
|
||||
['https://example.com/path?a=1|2', 'https://example.com/path?a=1|2'],
|
||||
['mailto:a@b.com', 'mailto:a@b.com'],
|
||||
['tel:+123', 'tel:+123'],
|
||||
])('normalizes %s → %s', (input, expected) => {
|
||||
expect(normalizeLinkUrl(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'javascript:alert(1)',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'vbscript:msgbox(1)',
|
||||
'file:///etc/passwd',
|
||||
'http://', // malformed → rejected via the catch
|
||||
])('rejects %s', (url) => {
|
||||
expect(isSafeUrl(url)).toBe(false);
|
||||
'#anchor', // no host once prepended → rejected
|
||||
'https://', // malformed → no host
|
||||
'',
|
||||
' ',
|
||||
])('rejects %s', (input) => {
|
||||
expect(normalizeLinkUrl(input)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { Editor } from '@tiptap/react';
|
||||
|
||||
import { ArrowUpRight, Check, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
|
||||
|
||||
import { normalizeLinkUrl } from './markdown-editor-toolbar-url';
|
||||
|
||||
interface LinkEditFormProps {
|
||||
// Focus the URL input on mount. True when the user explicitly opened the form (toolbar button); false for the
|
||||
// on-link popover, which appears whenever the caret enters a link and must not steal focus from the doc.
|
||||
autoFocus?: boolean;
|
||||
editor: Editor;
|
||||
initialUrl: string;
|
||||
isActive: boolean;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
// Shared body of the link editor — the same URL field + validation + Apply/Open/Remove used by the toolbar Link
|
||||
// popover AND the on-link popover (markdown-editor-link-handle.tsx). Seeds its own `url` from `initialUrl` on
|
||||
// mount, so consumers give it a fresh `key` per editing session.
|
||||
export function LinkEditForm({ autoFocus = true, editor, initialUrl, isActive, onDone }: LinkEditFormProps) {
|
||||
const [url, setUrl] = useState(initialUrl);
|
||||
|
||||
// Normalized absolute href (scheme prepended, protocol validated) or null when the input is unsafe/empty.
|
||||
const href = normalizeLinkUrl(url);
|
||||
const isInvalid = url !== '' && href === null;
|
||||
|
||||
const applyLink = () => {
|
||||
if (!href) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { empty } = editor.state.selection;
|
||||
|
||||
if (empty && !isActive) {
|
||||
// No selection to wrap → insert the URL as its own linked text (matches Docs/Notion). Shows what the
|
||||
// user typed but links to the normalized href.
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent({ marks: [{ attrs: { href }, type: 'link' }], text: url.trim(), type: 'text' })
|
||||
.run();
|
||||
} else {
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href }).run();
|
||||
}
|
||||
|
||||
onDone();
|
||||
};
|
||||
|
||||
const removeLink = () => {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
onDone();
|
||||
};
|
||||
|
||||
const openInNewTab = () => {
|
||||
if (href) {
|
||||
window.open(href, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
aria-invalid={isInvalid}
|
||||
aria-label="Link URL"
|
||||
autoFocus={autoFocus}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
applyLink();
|
||||
}
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
type="url"
|
||||
value={url}
|
||||
/>
|
||||
<InputGroupAddon
|
||||
align="inline-end"
|
||||
className="gap-0"
|
||||
>
|
||||
<InputGroupButton
|
||||
aria-label="Apply link"
|
||||
disabled={url === '' || isInvalid}
|
||||
onClick={applyLink}
|
||||
size="icon-xs"
|
||||
>
|
||||
<Check />
|
||||
</InputGroupButton>
|
||||
<InputGroupButton
|
||||
aria-label="Open link in new tab"
|
||||
disabled={url === '' || isInvalid}
|
||||
onClick={openInNewTab}
|
||||
size="icon-xs"
|
||||
>
|
||||
<ArrowUpRight />
|
||||
</InputGroupButton>
|
||||
{isActive ? (
|
||||
<InputGroupButton
|
||||
aria-label="Remove link"
|
||||
onClick={removeLink}
|
||||
size="icon-xs"
|
||||
>
|
||||
<Trash2 />
|
||||
</InputGroupButton>
|
||||
) : null}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{isInvalid ? (
|
||||
<p
|
||||
className="text-destructive text-xs"
|
||||
role="alert"
|
||||
>
|
||||
Only http, https, mailto and tel links are allowed.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { Editor } from '@tiptap/react';
|
||||
|
||||
import { getMarkRange, posToDOMRect } from '@tiptap/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
|
||||
|
||||
import { LinkEditForm } from './markdown-editor-link-edit-form';
|
||||
|
||||
interface LinkTarget {
|
||||
href: string;
|
||||
key: string;
|
||||
rect: { height: number; left: number; top: number; width: number };
|
||||
}
|
||||
|
||||
// Shows the shared link editor anchored to the link under the caret. openOnClick:false (markdown-editor-extensions)
|
||||
// seats the caret in a clicked link instead of navigating, so `selection.empty && isActive('link')` detects "the
|
||||
// caret is on a link" for both mouse and keyboard. Overlay-only — it never mutates the doc until a form action
|
||||
// runs, so it stays out of the byte round-trip (like TableHandles).
|
||||
export function LinkHandle({ editor }: { editor: Editor }) {
|
||||
const { close, target } = useLinkHandle(editor);
|
||||
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { href, key, rect } = target;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
close();
|
||||
}
|
||||
}}
|
||||
open
|
||||
>
|
||||
<PopoverAnchor asChild>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed"
|
||||
style={{ height: rect.height, left: rect.left, top: rect.top, width: rect.width }}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-80 p-2"
|
||||
data-link-handle=""
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
editor.commands.focus();
|
||||
}}
|
||||
onOpenAutoFocus={(event) => {
|
||||
// The popover appears whenever the caret enters a link, so keep focus in the doc — stealing it
|
||||
// would interrupt typing/navigation. The user clicks into the URL field only to actually edit.
|
||||
event.preventDefault();
|
||||
}}
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
>
|
||||
<LinkEditForm
|
||||
autoFocus={false}
|
||||
editor={editor}
|
||||
initialUrl={href}
|
||||
isActive
|
||||
key={key}
|
||||
onDone={close}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function useLinkHandle(editor: Editor) {
|
||||
const [target, setTarget] = useState<LinkTarget | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const linkType = editor.schema.marks.link;
|
||||
|
||||
const update = () => {
|
||||
const { selection } = editor.state;
|
||||
|
||||
if (!linkType || !selection.empty || !editor.isActive('link')) {
|
||||
setTarget(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const range = getMarkRange(selection.$from, linkType);
|
||||
|
||||
if (!range) {
|
||||
setTarget(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = posToDOMRect(editor.view, range.from, range.to);
|
||||
const href = (editor.getAttributes('link').href as string | undefined) ?? '';
|
||||
|
||||
setTarget({ href, key: `${range.from}-${range.to}`, rect });
|
||||
};
|
||||
|
||||
const clear = () => setTarget(null);
|
||||
|
||||
editor.on('selectionUpdate', update);
|
||||
|
||||
// Fixed-positioned anchor goes stale on scroll/resize — drop it (it reappears on the next caret entry).
|
||||
const scrollParent = editor.view.dom.closest('.tiptap-content') ?? window;
|
||||
|
||||
scrollParent.addEventListener('scroll', clear, { passive: true });
|
||||
window.addEventListener('resize', clear);
|
||||
|
||||
return () => {
|
||||
editor.off('selectionUpdate', update);
|
||||
scrollParent.removeEventListener('scroll', clear);
|
||||
window.removeEventListener('resize', clear);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return { close: () => setTarget(null), target };
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Editor } from '@tiptap/react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
import { AlignCenter, AlignLeft, AlignRight } from 'lucide-react';
|
||||
|
||||
export type ColumnAlign = 'center' | 'left' | 'right';
|
||||
|
||||
export const ALIGN_OPTIONS: { icon: LucideIcon; label: string; value: ColumnAlign }[] = [
|
||||
{ icon: AlignLeft, label: 'Left', value: 'left' },
|
||||
{ icon: AlignCenter, label: 'Center', value: 'center' },
|
||||
{ icon: AlignRight, label: 'Right', value: 'right' },
|
||||
];
|
||||
|
||||
// Empties every cell in the caret's row or column (Notion's "Clear contents"), leaving the structure intact.
|
||||
export function clearLineContents(editor: Editor, axis: 'column' | 'row'): void {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ dispatch, editor: instance, tr }) => {
|
||||
const paragraphType = instance.schema.nodes.paragraph;
|
||||
|
||||
if (dispatch && paragraphType) {
|
||||
// Descending order so each replacement leaves earlier cell positions valid.
|
||||
for (const pos of cellPositionsInLine(editor, axis).reverse()) {
|
||||
const cell = tr.doc.nodeAt(pos);
|
||||
|
||||
if (cell) {
|
||||
tr.replaceWith(pos + 1, pos + cell.nodeSize - 1, paragraphType.create());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
// Whether a table uses a header row (its first row holds `tableHeader` cells). Defaults to the table around the
|
||||
// caret; pass `pos` to read a specific cell's table instead — the hover grips open without moving the selection,
|
||||
// so they must resolve the hovered table by position rather than the caret's last table.
|
||||
export function hasHeaderRow(editor: Editor, pos?: number): boolean {
|
||||
const $pos = pos == null ? editor.state.selection.$from : editor.state.doc.resolve(pos);
|
||||
|
||||
for (let depth = $pos.depth; depth > 0; depth--) {
|
||||
if ($pos.node(depth).type.name === 'table') {
|
||||
return $pos.node(depth).firstChild?.firstChild?.type.name === 'tableHeader';
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// GFM aligns whole COLUMNS (the delimiter row), stored by @tiptap/extension-table as an `align` attr on every
|
||||
// cell — setCellAttribute only touches the caret cell, so set it on the whole column or only one cell aligns
|
||||
// until the doc reloads.
|
||||
export function setColumnAlign(editor: Editor, align: ColumnAlign): void {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ dispatch, tr }) => {
|
||||
if (dispatch) {
|
||||
for (const pos of cellPositionsInLine(editor, 'column')) {
|
||||
tr.setNodeAttribute(pos, 'align', align);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
// Walks the table containing the caret and yields the doc position of every cell in the caret's column (or row).
|
||||
// colspan is always 1 here — GFM tables have no merged cells — so a cell's index within its row IS its column.
|
||||
function cellPositionsInLine(editor: Editor, axis: 'column' | 'row'): number[] {
|
||||
const { $from } = editor.state.selection;
|
||||
let depth = $from.depth;
|
||||
|
||||
while (depth > 0 && !['tableCell', 'tableHeader'].includes($from.node(depth).type.name)) {
|
||||
depth--;
|
||||
}
|
||||
|
||||
if (depth === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const targetIndex = axis === 'column' ? $from.index(depth - 1) : $from.index(depth - 2);
|
||||
const tableDepth = depth - 2;
|
||||
const tablePos = $from.before(tableDepth);
|
||||
const positions: number[] = [];
|
||||
|
||||
$from.node(tableDepth).forEach((row, rowOffset, rowIndex) => {
|
||||
row.forEach((cell, cellOffset, cellIndex) => {
|
||||
const matches = axis === 'column' ? cellIndex === targetIndex : rowIndex === targetIndex;
|
||||
|
||||
if (matches) {
|
||||
positions.push(tablePos + 1 + rowOffset + 1 + cellOffset);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return positions;
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import type { Editor } from '@tiptap/react';
|
||||
|
||||
import { useEditorState } from '@tiptap/react';
|
||||
import {
|
||||
AlignLeft,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
Delete,
|
||||
Eraser,
|
||||
GripHorizontal,
|
||||
GripVertical,
|
||||
PanelTop,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
import { ALIGN_OPTIONS, clearLineContents, hasHeaderRow, setColumnAlign } from './markdown-editor-table-commands';
|
||||
|
||||
interface HoverTarget {
|
||||
cellPos: number;
|
||||
colLeft: number;
|
||||
colWidth: number;
|
||||
rowHeight: number;
|
||||
rowTop: number;
|
||||
tableLeft: number;
|
||||
tableTop: number;
|
||||
}
|
||||
|
||||
type OpenMenu = 'column' | 'row' | null;
|
||||
|
||||
const GRIP = 18;
|
||||
const GRIP_CLASS =
|
||||
'bg-muted hover:bg-accent text-muted-foreground hover:text-accent-foreground flex items-center justify-center rounded border shadow-sm transition-colors';
|
||||
|
||||
interface TableHandlesController {
|
||||
onMenuChange: (menu: 'column' | 'row') => (isOpen: boolean) => void;
|
||||
open: OpenMenu;
|
||||
target: HoverTarget | null;
|
||||
}
|
||||
|
||||
// Notion-style hover handles: a grip appears above the hovered column and left of the hovered row; clicking it
|
||||
// opens a shadcn menu of markdown-safe row/column operations. Overlay-only — it never mutates the document until
|
||||
// a menu item runs — so it stays out of the byte round-trip. Merged cells / colour / header-column are omitted
|
||||
// (not GFM-representable); the toolbar Table dropdown carries the same ops for keyboard/no-hover users.
|
||||
export function TableHandles({ editor }: { editor: Editor }) {
|
||||
const { onMenuChange, open, target } = useTableHandles(editor);
|
||||
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const focusTarget = () => editor.chain().focus().setTextSelection(target.cellPos);
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<DropdownMenu
|
||||
onOpenChange={onMenuChange('column')}
|
||||
open={open === 'column'}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label="Column actions"
|
||||
className={GRIP_CLASS}
|
||||
data-table-grip=""
|
||||
style={{
|
||||
height: GRIP,
|
||||
left: target.colLeft + target.colWidth / 2 - GRIP / 2,
|
||||
position: 'fixed',
|
||||
top: target.tableTop - GRIP / 2,
|
||||
width: GRIP,
|
||||
zIndex: 40,
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<GripHorizontal className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="min-w-[176px]"
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
editor.commands.focus();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem onSelect={() => focusTarget().addColumnBefore().run()}>
|
||||
<ArrowLeft className="text-muted-foreground size-4 shrink-0" />
|
||||
Insert left
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => focusTarget().addColumnAfter().run()}>
|
||||
<ArrowRight className="text-muted-foreground size-4 shrink-0" />
|
||||
Insert right
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<AlignLeft className="text-muted-foreground size-4 shrink-0" />
|
||||
Align column
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{ALIGN_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
focusTarget().run();
|
||||
setColumnAlign(editor, option.value);
|
||||
}}
|
||||
>
|
||||
<option.icon className="text-muted-foreground size-4 shrink-0" />
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
focusTarget().run();
|
||||
clearLineContents(editor, 'column');
|
||||
}}
|
||||
>
|
||||
<Eraser className="text-muted-foreground size-4 shrink-0" />
|
||||
Clear contents
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => focusTarget().deleteColumn().run()}>
|
||||
<Delete className="text-muted-foreground size-4 shrink-0" />
|
||||
Delete column
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu
|
||||
onOpenChange={onMenuChange('row')}
|
||||
open={open === 'row'}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label="Row actions"
|
||||
className={GRIP_CLASS}
|
||||
data-table-grip=""
|
||||
style={{
|
||||
height: GRIP,
|
||||
left: target.tableLeft - GRIP / 2,
|
||||
position: 'fixed',
|
||||
top: target.rowTop + target.rowHeight / 2 - GRIP / 2,
|
||||
width: GRIP,
|
||||
zIndex: 40,
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<GripVertical className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="min-w-[176px]"
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
editor.commands.focus();
|
||||
}}
|
||||
>
|
||||
<RowHeaderToggleItem
|
||||
cellPos={target.cellPos}
|
||||
editor={editor}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => focusTarget().addRowBefore().run()}>
|
||||
<ArrowUp className="text-muted-foreground size-4 shrink-0" />
|
||||
Insert above
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => focusTarget().addRowAfter().run()}>
|
||||
<ArrowDown className="text-muted-foreground size-4 shrink-0" />
|
||||
Insert below
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
focusTarget().run();
|
||||
clearLineContents(editor, 'row');
|
||||
}}
|
||||
>
|
||||
<Eraser className="text-muted-foreground size-4 shrink-0" />
|
||||
Clear contents
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => focusTarget().deleteRow().run()}>
|
||||
<Delete className="text-muted-foreground size-4 shrink-0 -rotate-90" />
|
||||
Delete row
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// Its useEditorState subscription must stay inside the row-grip DropdownMenuContent: Radix Presence unmounts it
|
||||
// on close, so the deliberately non-reactive useTableHandles hover path pays no per-transaction cost while idle.
|
||||
// Reads header state by the hovered cell's position, not the caret — the grip opens without moving the selection.
|
||||
function RowHeaderToggleItem({ cellPos, editor }: { cellPos: number; editor: Editor }) {
|
||||
const isHeaderRow = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor }) => hasHeaderRow(editor, cellPos),
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
aria-checked={isHeaderRow}
|
||||
onSelect={(event) => {
|
||||
// preventDefault keeps the menu open so the switch flips in place; setTextSelection (not .focus())
|
||||
// seats the toggle in the hovered table without pulling DOM focus off the open menu.
|
||||
event.preventDefault();
|
||||
editor.chain().setTextSelection(cellPos).toggleHeaderRow().run();
|
||||
}}
|
||||
role="menuitemcheckbox"
|
||||
>
|
||||
<PanelTop className="text-muted-foreground size-4 shrink-0" />
|
||||
<span>Header row</span>
|
||||
<Switch
|
||||
checked={isHeaderRow}
|
||||
className="pointer-events-none ml-auto"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
// Headless controller: owns every imperative DOM touch — hover tracking over ProseMirror's (non-React) cells,
|
||||
// layout measurement, and the scroll/menu-freeze bookkeeping — and hands the view a plain declarative state.
|
||||
// Swapping the hover strategy (e.g. to a ProseMirror decoration plugin) means rewriting only this hook.
|
||||
function useTableHandles(editor: Editor): TableHandlesController {
|
||||
const [target, setTarget] = useState<HoverTarget | null>(null);
|
||||
const [open, setOpen] = useState<OpenMenu>(null);
|
||||
const clearTimer = useRef<null | ReturnType<typeof setTimeout>>(null);
|
||||
const openRef = useRef<OpenMenu>(null);
|
||||
const cellPosRef = useRef<null | number>(null);
|
||||
|
||||
useEffect(() => {
|
||||
openRef.current = open;
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dom = editor.view.dom;
|
||||
|
||||
const cancelClear = () => {
|
||||
if (clearTimer.current) {
|
||||
clearTimeout(clearTimer.current);
|
||||
clearTimer.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearSoon = () => {
|
||||
cancelClear();
|
||||
clearTimer.current = setTimeout(() => {
|
||||
if (!openRef.current) {
|
||||
cellPosRef.current = null;
|
||||
setTarget(null);
|
||||
}
|
||||
}, 120);
|
||||
};
|
||||
|
||||
// One document-level listener rather than the editor's own: the grip straddles the table border, so the
|
||||
// editor's mouseleave fires the instant the cursor crosses onto it and it would flicker away. Here we
|
||||
// classify each move — over a grip (keep), over a cell (reposition), over neither (hide) — so travelling
|
||||
// from a cell onto the grip never clears. `cellPosRef` skips re-renders while the cursor stays in one cell.
|
||||
const handleMove = (event: MouseEvent) => {
|
||||
if (openRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = event.target as HTMLElement | null;
|
||||
|
||||
if (el?.closest?.('[data-table-grip]')) {
|
||||
cancelClear();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const cell = el?.closest?.('td, th');
|
||||
|
||||
if (!(cell instanceof HTMLElement) || !dom.contains(cell)) {
|
||||
if (cellPosRef.current !== null) {
|
||||
clearSoon();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
cancelClear();
|
||||
|
||||
let cellPos: number;
|
||||
|
||||
try {
|
||||
cellPos = editor.view.posAtDOM(cell, 0);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cellPos === cellPosRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const row = cell.closest('tr');
|
||||
const table = cell.closest('table');
|
||||
|
||||
if (!row || !table) {
|
||||
return;
|
||||
}
|
||||
|
||||
cellPosRef.current = cellPos;
|
||||
|
||||
const cellRect = cell.getBoundingClientRect();
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
const tableRect = table.getBoundingClientRect();
|
||||
|
||||
setTarget({
|
||||
cellPos,
|
||||
colLeft: cellRect.left,
|
||||
colWidth: cellRect.width,
|
||||
rowHeight: rowRect.height,
|
||||
rowTop: rowRect.top,
|
||||
tableLeft: tableRect.left,
|
||||
tableTop: tableRect.top,
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMove);
|
||||
|
||||
// Fixed-positioned grips go stale on scroll — drop them (they reappear on the next hover).
|
||||
const scrollParent = dom.closest('.tiptap-content') ?? window;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!openRef.current) {
|
||||
cellPosRef.current = null;
|
||||
setTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
scrollParent.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
cancelClear();
|
||||
document.removeEventListener('mousemove', handleMove);
|
||||
scrollParent.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const onMenuChange = (menu: 'column' | 'row') => (isOpen: boolean) => {
|
||||
setOpen(isOpen ? menu : null);
|
||||
|
||||
if (!isOpen) {
|
||||
cellPosRef.current = null;
|
||||
setTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
return { onMenuChange, open, target };
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Toggle } from '@/components/ui/toggle';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
shortcut?: string;
|
||||
}
|
||||
|
||||
interface ToolbarToggleProps {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
onPressedChange: () => void;
|
||||
pressed: boolean;
|
||||
shortcut?: string;
|
||||
}
|
||||
|
||||
interface ToolbarTooltipProps {
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
}
|
||||
|
||||
// One-shot actions (undo, insert…). A plain button — no aria-pressed, unlike a Toggle.
|
||||
export function ToolbarButton({ children, disabled, label, onClick, shortcut }: ToolbarButtonProps) {
|
||||
return (
|
||||
<ToolbarTooltip
|
||||
label={label}
|
||||
shortcut={shortcut}
|
||||
>
|
||||
<Button
|
||||
aria-label={label}
|
||||
data-toolbar-item=""
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size="icon-sm"
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</ToolbarTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Marks/blocks with a genuine on/off state (bold, italic, lists…). Renders aria-pressed via Radix Toggle.
|
||||
export function ToolbarToggle({ children, disabled, label, onPressedChange, pressed, shortcut }: ToolbarToggleProps) {
|
||||
return (
|
||||
<ToolbarTooltip
|
||||
label={label}
|
||||
shortcut={shortcut}
|
||||
>
|
||||
<Toggle
|
||||
aria-label={label}
|
||||
data-toolbar-item=""
|
||||
disabled={disabled}
|
||||
onPressedChange={onPressedChange}
|
||||
pressed={pressed}
|
||||
size="sm"
|
||||
>
|
||||
{children}
|
||||
</Toggle>
|
||||
</ToolbarTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarTooltip({ children, label, shortcut }: ToolbarTooltipProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent className="flex items-center gap-2">
|
||||
<span>{label}</span>
|
||||
{shortcut ? (
|
||||
<kbd className="bg-muted text-muted-foreground rounded px-1 font-mono text-[10px]">{shortcut}</kbd>
|
||||
) : null}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Editor } from '@tiptap/react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
import { Check, ChevronDown, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Type } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
interface HeadingOption {
|
||||
icon: LucideIcon;
|
||||
// Optical trim for the dropdown list ONLY (icons sit next to each other there): lucide's Type glyph is drawn
|
||||
// taller (16u) than the Heading glyphs (12u), so at the same 16px box it reads bigger — scale it down to match
|
||||
// without shrinking the box (which would misalign labels). The trigger shows one icon alone, so it stays full size.
|
||||
iconClassName?: string;
|
||||
label: string;
|
||||
value: 'paragraph' | HeadingLevel;
|
||||
}
|
||||
|
||||
const OPTIONS: HeadingOption[] = [
|
||||
{ icon: Heading1, label: 'Heading 1', value: 1 },
|
||||
{ icon: Heading2, label: 'Heading 2', value: 2 },
|
||||
{ icon: Heading3, label: 'Heading 3', value: 3 },
|
||||
{ icon: Heading4, label: 'Heading 4', value: 4 },
|
||||
{ icon: Heading5, label: 'Heading 5', value: 5 },
|
||||
{ icon: Heading6, label: 'Heading 6', value: 6 },
|
||||
{ icon: Type, iconClassName: 'scale-[0.75]', label: 'Text', value: 'paragraph' },
|
||||
];
|
||||
|
||||
interface HeadingMenuProps {
|
||||
// 0 = paragraph / any non-heading block; 1-6 = the active heading level.
|
||||
activeLevel: 0 | HeadingLevel;
|
||||
disabled?: boolean;
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
export function HeadingMenu({ activeLevel, disabled, editor }: HeadingMenuProps) {
|
||||
const isSelected = (value: HeadingOption['value']) =>
|
||||
value === 'paragraph' ? activeLevel === 0 : value === activeLevel;
|
||||
const active = OPTIONS.find((option) => isSelected(option.value)) ?? OPTIONS[0];
|
||||
const ActiveIcon = active?.icon ?? Type;
|
||||
|
||||
const applyOption = (value: HeadingOption['value']) => {
|
||||
if (value === 'paragraph') {
|
||||
editor.chain().focus().setParagraph().run();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
editor.chain().focus().toggleHeading({ level: value }).run();
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={`Text style: ${active?.label ?? 'Text'}`}
|
||||
className="gap-0.5 px-1.5"
|
||||
data-toolbar-item=""
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ActiveIcon />
|
||||
<ChevronDown className="size-4 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Text style</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="min-w-[140px]"
|
||||
onCloseAutoFocus={(event) => {
|
||||
// Return focus to the editor caret (not the trigger button) so the user keeps typing.
|
||||
event.preventDefault();
|
||||
editor.commands.focus();
|
||||
}}
|
||||
>
|
||||
{OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onSelect={() => applyOption(option.value)}
|
||||
>
|
||||
<option.icon className={cn('text-muted-foreground size-4 shrink-0', option.iconClassName)} />
|
||||
<span>{option.label}</span>
|
||||
{isSelected(option.value) ? <Check className="ml-auto size-4 shrink-0" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Editor } from '@tiptap/react';
|
||||
|
||||
import { ImagePlus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
import { ImageEditForm } from './markdown-editor-image-edit-form';
|
||||
|
||||
interface ImagePopoverProps {
|
||||
disabled?: boolean;
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
export function ImagePopover({ disabled, editor }: ImagePopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onOpenChange={setOpen}
|
||||
open={open}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
aria-label="Insert image"
|
||||
data-toolbar-item=""
|
||||
disabled={disabled}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ImagePlus />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Insert image</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-80"
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
editor.commands.focus();
|
||||
}}
|
||||
>
|
||||
<ImageEditForm
|
||||
editor={editor}
|
||||
initialAlt=""
|
||||
initialSrc=""
|
||||
isEditing={false}
|
||||
onDone={() => setOpen(false)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Editor } from '@tiptap/react';
|
||||
|
||||
import { Link as LinkIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Toggle } from '@/components/ui/toggle';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
import { LinkEditForm } from './markdown-editor-link-edit-form';
|
||||
|
||||
interface LinkPopoverProps {
|
||||
disabled?: boolean;
|
||||
editor: Editor;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function LinkPopover({ disabled, editor, isActive }: LinkPopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onOpenChange={setOpen}
|
||||
open={open}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Toggle
|
||||
aria-label="Link"
|
||||
data-toolbar-item=""
|
||||
disabled={disabled}
|
||||
pressed={isActive}
|
||||
size="sm"
|
||||
>
|
||||
<LinkIcon />
|
||||
</Toggle>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Link</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-80 p-2"
|
||||
onCloseAutoFocus={(event) => {
|
||||
// Return focus to the editor caret (not the trigger) so the user keeps typing after apply/cancel.
|
||||
event.preventDefault();
|
||||
editor.commands.focus();
|
||||
}}
|
||||
>
|
||||
<LinkEditForm
|
||||
editor={editor}
|
||||
initialUrl={(editor.getAttributes('link').href as string | undefined) ?? ''}
|
||||
isActive={isActive}
|
||||
onDone={() => setOpen(false)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Editor } from '@tiptap/react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
import { Check, ChevronDown, List, ListOrdered, ListTodo } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type ListType = 'bullet' | 'ordered' | 'task';
|
||||
|
||||
interface ListOption {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: ListType;
|
||||
}
|
||||
|
||||
const OPTIONS: ListOption[] = [
|
||||
{ icon: List, label: 'Bullet list', value: 'bullet' },
|
||||
{ icon: ListOrdered, label: 'Ordered list', value: 'ordered' },
|
||||
{ icon: ListTodo, label: 'Task list', value: 'task' },
|
||||
];
|
||||
|
||||
interface ListMenuProps {
|
||||
activeType: ListType | null;
|
||||
disabled?: boolean;
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
export function ListMenu({ activeType, disabled, editor }: ListMenuProps) {
|
||||
const active = OPTIONS.find((option) => option.value === activeType);
|
||||
// The bullet-list icon doubles as the resting affordance, so the active background — not the glyph — is what
|
||||
// distinguishes "in a bullet list" from "no list".
|
||||
const TriggerIcon = active?.icon ?? List;
|
||||
|
||||
const applyOption = (value: ListType) => {
|
||||
const chain = editor.chain().focus();
|
||||
|
||||
if (value === 'bullet') {
|
||||
chain.toggleBulletList().run();
|
||||
} else if (value === 'ordered') {
|
||||
chain.toggleOrderedList().run();
|
||||
} else {
|
||||
chain.toggleTaskList().run();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={`List: ${active?.label ?? 'None'}`}
|
||||
className={cn('gap-0.5 px-1.5', active && 'bg-accent text-accent-foreground')}
|
||||
data-toolbar-item=""
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<TriggerIcon />
|
||||
<ChevronDown className="size-4 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Lists</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="min-w-[160px]"
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
editor.commands.focus();
|
||||
}}
|
||||
>
|
||||
{OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onSelect={() => applyOption(option.value)}
|
||||
>
|
||||
<option.icon className="text-muted-foreground size-4 shrink-0" />
|
||||
<span>{option.label}</span>
|
||||
{activeType === option.value ? <Check className="ml-auto size-4 shrink-0" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { Editor } from '@tiptap/react';
|
||||
|
||||
import {
|
||||
AlignLeft,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Delete,
|
||||
PanelTop,
|
||||
Table as TableIcon,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { ALIGN_OPTIONS, type ColumnAlign, setColumnAlign } from './markdown-editor-table-commands';
|
||||
|
||||
export type { ColumnAlign };
|
||||
|
||||
interface TableMenuProps {
|
||||
columnAlign: ColumnAlign | null;
|
||||
disabled?: boolean;
|
||||
editor: Editor;
|
||||
isActive: boolean;
|
||||
isHeaderRow: boolean;
|
||||
}
|
||||
|
||||
export function TableMenu({ columnAlign, disabled, editor, isActive, isHeaderRow }: TableMenuProps) {
|
||||
const run = (fn: (chain: ReturnType<Editor['chain']>) => ReturnType<Editor['chain']>) =>
|
||||
fn(editor.chain().focus()).run();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Table"
|
||||
className={cn('gap-0.5 px-1.5', isActive && 'bg-accent text-accent-foreground')}
|
||||
data-toolbar-item=""
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<TableIcon />
|
||||
<ChevronDown className="size-4 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Table</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="min-w-[180px]"
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
editor.commands.focus();
|
||||
}}
|
||||
>
|
||||
{isActive ? (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
aria-checked={isHeaderRow}
|
||||
onSelect={(event) => {
|
||||
// Keep the menu open so the switch animates in place, like Notion's block menu.
|
||||
event.preventDefault();
|
||||
editor.chain().toggleHeaderRow().run();
|
||||
}}
|
||||
role="menuitemcheckbox"
|
||||
>
|
||||
<PanelTop className="text-muted-foreground size-4 shrink-0" />
|
||||
<span>Header row</span>
|
||||
<Switch
|
||||
checked={isHeaderRow}
|
||||
className="pointer-events-none ml-auto"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => run((chain) => chain.addRowBefore())}>
|
||||
<ArrowUp className="text-muted-foreground size-4 shrink-0" />
|
||||
Insert row above
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => run((chain) => chain.addRowAfter())}>
|
||||
<ArrowDown className="text-muted-foreground size-4 shrink-0" />
|
||||
Insert row below
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => run((chain) => chain.addColumnBefore())}>
|
||||
<ArrowLeft className="text-muted-foreground size-4 shrink-0" />
|
||||
Insert column left
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => run((chain) => chain.addColumnAfter())}>
|
||||
<ArrowRight className="text-muted-foreground size-4 shrink-0" />
|
||||
Insert column right
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<AlignLeft className="text-muted-foreground size-4 shrink-0" />
|
||||
Align column
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{ALIGN_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onSelect={() => setColumnAlign(editor, option.value)}
|
||||
>
|
||||
<option.icon className="text-muted-foreground size-4 shrink-0" />
|
||||
<span>{option.label}</span>
|
||||
{(columnAlign ?? 'left') === option.value ? (
|
||||
<Check className="ml-auto size-4 shrink-0" />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => run((chain) => chain.deleteRow())}>
|
||||
<Delete className="text-muted-foreground size-4 shrink-0 -rotate-90" />
|
||||
Delete row
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => run((chain) => chain.deleteColumn())}>
|
||||
<Delete className="text-muted-foreground size-4 shrink-0" />
|
||||
Delete column
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => run((chain) => chain.deleteTable())}>
|
||||
<Trash2 className="text-muted-foreground size-4 shrink-0" />
|
||||
Delete table
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => run((chain) => chain.insertTable({ cols: 3, rows: 3, withHeaderRow: true }))}
|
||||
>
|
||||
<TableIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
Insert table
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Authoring-time protocol allowlists for the toolbar's Link and Image popovers. They keep javascript:/data:text
|
||||
// (and, for images, script-capable data:image/svg+xml) out of the persisted document so fidelity never depends on
|
||||
// every future render path sanitizing them. Load/paste bypass these — tiptap Link's isAllowedUri and the
|
||||
// read-only viewer sanitize protocols on render; an <img src> is inert regardless.
|
||||
|
||||
const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
|
||||
|
||||
const KNOWN_LINK_SCHEME = /^(?:https?:\/\/|mailto:|tel:)/i;
|
||||
|
||||
// A scheme-less link ("example.com", "localhost:3000") must be made absolute, or the browser resolves it against
|
||||
// the current origin as a relative path. Prepend https:// unless the input already carries an allowed scheme, then
|
||||
// validate the protocol. Validate with NO base URL — passing window.location as base (as a plain protocol check
|
||||
// would) launders a relative value into https: and is exactly the bug this replaces. Returns the normalized href,
|
||||
// or null for unsafe/malformed input (javascript:/data:/file:, empty). Neither tiptap nor its UI normalize manual
|
||||
// entry — both persist the raw href verbatim — so we do it here.
|
||||
export const normalizeLinkUrl = (raw: string): null | string => {
|
||||
const url = raw.trim();
|
||||
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = KNOWN_LINK_SCHEME.test(url) ? url : `https://${url.replace(/^\/+/, '')}`;
|
||||
|
||||
try {
|
||||
return SAFE_LINK_PROTOCOLS.has(new URL(candidate).protocol) ? candidate : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const RASTER_IMAGE_DATA = /^data:image\/(?:png|jpe?g|gif|webp|bmp);base64,/i;
|
||||
|
||||
// Image analog of normalizeLinkUrl: a scheme-less src ("example.com/a.png") is made absolute with https://; an
|
||||
// already-schemed http(s) URL and a base64 raster data: URL pass through. data:image/svg+xml is rejected — SVG
|
||||
// can carry script. Returns the normalized src, or null for unsafe/malformed input.
|
||||
export const normalizeImageSrc = (raw: string): null | string => {
|
||||
const src = raw.trim();
|
||||
|
||||
if (!src) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = /^(?:https?:\/\/|data:)/i.test(src) ? src : `https://${src.replace(/^\/+/, '')}`;
|
||||
|
||||
if (/^data:/i.test(candidate)) {
|
||||
return RASTER_IMAGE_DATA.test(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { protocol } = new URL(candidate);
|
||||
|
||||
return protocol === 'http:' || protocol === 'https:' ? candidate : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -4,317 +4,374 @@ import { useEditorState } from '@tiptap/react';
|
||||
import {
|
||||
Bold,
|
||||
Code,
|
||||
Code2,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
ImagePlus,
|
||||
Italic,
|
||||
Link as LinkIcon,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListTodo,
|
||||
Minus,
|
||||
Quote,
|
||||
Redo,
|
||||
RemoveFormatting,
|
||||
SquareCode,
|
||||
Strikethrough,
|
||||
Table,
|
||||
Undo,
|
||||
} from 'lucide-react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Toggle } from '@/components/ui/toggle';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { HeadingLevel } from './markdown-editor-toolbar-heading';
|
||||
import type { ListType } from './markdown-editor-toolbar-list';
|
||||
import type { ColumnAlign } from './markdown-editor-toolbar-table';
|
||||
|
||||
import { hasHeaderRow } from './markdown-editor-table-commands';
|
||||
import { ToolbarButton, ToolbarToggle } from './markdown-editor-toolbar-button';
|
||||
import { HeadingMenu } from './markdown-editor-toolbar-heading';
|
||||
import { ImagePopover } from './markdown-editor-toolbar-image';
|
||||
import { LinkPopover } from './markdown-editor-toolbar-link';
|
||||
import { ListMenu } from './markdown-editor-toolbar-list';
|
||||
import { TableMenu } from './markdown-editor-toolbar-table';
|
||||
|
||||
interface MarkdownEditorToolbarProps {
|
||||
disabled?: boolean;
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
// The Image extension doesn't validate the src protocol, so the toolbar Insert-image button rejects
|
||||
// non-http(s)/non-raster-data URLs (javascript:, data:text/html, data:image/svg+xml — SVG can carry script).
|
||||
// Guards ONLY that button — images entering via markdown load/paste bypass it (inert in an <img src>; the
|
||||
// read-only viewer sanitizes protocols on render).
|
||||
export const isSafeImageSrc = (url: string): boolean => {
|
||||
try {
|
||||
const { protocol } = new URL(url, window.location.href);
|
||||
const HEADING_LEVELS: HeadingLevel[] = [1, 2, 3, 4, 5, 6];
|
||||
|
||||
return (
|
||||
protocol === 'http:' || protocol === 'https:' || /^data:image\/(png|jpe?g|gif|webp|bmp);base64,/i.test(url)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// A mouse wheel emits deltaY, which the browser applies to the nearest VERTICAL scroller — never to this
|
||||
// overflow-x strip, so a wheel-mouse user can't reach overflowed controls (trackpads emit deltaX and already
|
||||
// work). Translate a vertical-dominant wheel into horizontal scroll, releasing at the ends so the page can
|
||||
// still scroll past the toolbar. Attached non-passively — React's onWheel is passive, so it can't preventDefault.
|
||||
function useHorizontalWheelScroll(ref: React.RefObject<HTMLDivElement | null>) {
|
||||
useEffect(() => {
|
||||
const strip = ref.current;
|
||||
|
||||
// Link-toolbar counterpart of isSafeImageSrc: only navigable protocols (relative/anchor URLs resolve to the
|
||||
// page's http/https). Keeps `javascript:` / `data:` out of the persisted document at authoring time so it
|
||||
// never depends on every future render path sanitizing them. Load/paste bypass this (tiptap Link's
|
||||
// isAllowedUri + the read-only viewer sanitize protocols on render).
|
||||
const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
|
||||
if (!strip) {
|
||||
return;
|
||||
}
|
||||
|
||||
export const isSafeUrl = (url: string): boolean => {
|
||||
try {
|
||||
return SAFE_LINK_PROTOCOLS.has(new URL(url, window.location.href).protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (strip.scrollWidth <= strip.clientWidth || Math.abs(event.deltaX) >= Math.abs(event.deltaY)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// memo: every keystroke re-renders the RHF-controlled parent; without it all ~20 Toggle subtrees re-render
|
||||
const atStart = strip.scrollLeft <= 0 && event.deltaY < 0;
|
||||
const atEnd = strip.scrollLeft + strip.clientWidth >= strip.scrollWidth && event.deltaY > 0;
|
||||
|
||||
if (atStart || atEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
strip.scrollLeft += event.deltaY;
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
strip.addEventListener('wheel', handleWheel, { passive: false });
|
||||
|
||||
return () => strip.removeEventListener('wheel', handleWheel);
|
||||
}, [ref]);
|
||||
}
|
||||
|
||||
// WAI-ARIA toolbar pattern: one Tab stop for the whole bar, Arrow/Home/End move between controls. Managed
|
||||
// imperatively on `[data-toolbar-item]` so each control stays a dumb button; the set changes (the table
|
||||
// control swaps button↔menu, controls disable) so a MutationObserver re-seeds the single tab stop. When a
|
||||
// popover/dropdown is open its focus lives in a body portal outside the bar, so activeElement isn't an item
|
||||
// and Arrow keys fall through to that menu instead of being hijacked here.
|
||||
function useToolbarRovingFocus(ref: React.RefObject<HTMLDivElement | null>) {
|
||||
useEffect(() => {
|
||||
const toolbar = ref.current;
|
||||
|
||||
if (!toolbar) {
|
||||
return;
|
||||
}
|
||||
|
||||
const enabledItems = () =>
|
||||
Array.from(toolbar.querySelectorAll<HTMLElement>('[data-toolbar-item]')).filter(
|
||||
(item) => !item.hasAttribute('disabled'),
|
||||
);
|
||||
|
||||
const seedTabStop = () => {
|
||||
const items = enabledItems();
|
||||
const active = items.find((item) => item.tabIndex === 0) ?? items[0] ?? null;
|
||||
|
||||
for (const item of toolbar.querySelectorAll<HTMLElement>('[data-toolbar-item]')) {
|
||||
item.tabIndex = item === active ? 0 : -1;
|
||||
}
|
||||
};
|
||||
|
||||
seedTabStop();
|
||||
|
||||
const observer = new MutationObserver(seedTabStop);
|
||||
observer.observe(toolbar, { attributeFilter: ['disabled'], childList: true, subtree: true });
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'End', 'Home'].includes(event.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = enabledItems();
|
||||
const index = items.indexOf(document.activeElement as HTMLElement);
|
||||
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const nextIndex =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? items.length - 1
|
||||
: event.key === 'ArrowRight'
|
||||
? (index + 1) % items.length
|
||||
: (index - 1 + items.length) % items.length;
|
||||
const next = items[nextIndex];
|
||||
|
||||
if (!next) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
item.tabIndex = item === next ? 0 : -1;
|
||||
}
|
||||
|
||||
next.focus();
|
||||
};
|
||||
|
||||
toolbar.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
toolbar.removeEventListener('keydown', handleKeyDown);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [ref]);
|
||||
}
|
||||
|
||||
// memo: every keystroke re-renders the RHF-controlled parent; without it all Toggle/Button subtrees re-render
|
||||
// per keystroke for referentially-stable props.
|
||||
export const MarkdownEditorToolbar = memo(function MarkdownEditorToolbar({
|
||||
disabled,
|
||||
editor,
|
||||
}: MarkdownEditorToolbarProps) {
|
||||
const handleSetLink = useCallback(() => {
|
||||
const previousUrl = editor.getAttributes('link').href as string | undefined;
|
||||
const url = window.prompt('URL', previousUrl ?? '');
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url === '') {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSafeUrl(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||
}, [editor]);
|
||||
|
||||
// tiptap v3's useEditor does NOT re-render on every transaction, so reading editor.isActive/can() inline
|
||||
// would leave the toolbar stale on selection-only moves (click into a bold word → Bold stays unlit).
|
||||
// useEditorState re-runs this selector per transaction and re-renders only when a button's state flips.
|
||||
// useEditorState re-runs this selector per transaction and re-renders only when a value flips.
|
||||
const state = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor }) => ({
|
||||
activeListType: (editor.isActive('bulletList')
|
||||
? 'bullet'
|
||||
: editor.isActive('orderedList')
|
||||
? 'ordered'
|
||||
: editor.isActive('taskList')
|
||||
? 'task'
|
||||
: null) as ListType | null,
|
||||
canRedo: editor.can().redo(),
|
||||
canUndo: editor.can().undo(),
|
||||
columnAlign: (editor.getAttributes('tableHeader').align ??
|
||||
editor.getAttributes('tableCell').align ??
|
||||
null) as ColumnAlign | null,
|
||||
headingLevel: (HEADING_LEVELS.find((level) => editor.isActive('heading', { level })) ?? 0) as
|
||||
| 0
|
||||
| HeadingLevel,
|
||||
isBlockquote: editor.isActive('blockquote'),
|
||||
isBold: editor.isActive('bold'),
|
||||
isBulletList: editor.isActive('bulletList'),
|
||||
isCode: editor.isActive('code'),
|
||||
isCodeBlock: editor.isActive('codeBlock'),
|
||||
isH1: editor.isActive('heading', { level: 1 }),
|
||||
isH2: editor.isActive('heading', { level: 2 }),
|
||||
isH3: editor.isActive('heading', { level: 3 }),
|
||||
isHeaderRow: hasHeaderRow(editor),
|
||||
isItalic: editor.isActive('italic'),
|
||||
isLink: editor.isActive('link'),
|
||||
isOrderedList: editor.isActive('orderedList'),
|
||||
isStrike: editor.isActive('strike'),
|
||||
isTable: editor.isActive('table'),
|
||||
isTaskList: editor.isActive('taskList'),
|
||||
}),
|
||||
});
|
||||
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useToolbarRovingFocus(toolbarRef);
|
||||
useHorizontalWheelScroll(stripRef);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-muted/40 flex flex-wrap items-center gap-0.5 border-b px-1 py-1',
|
||||
disabled && 'pointer-events-none opacity-60',
|
||||
)}
|
||||
data-slot="markdown-editor-toolbar"
|
||||
>
|
||||
<Toggle
|
||||
aria-label="Bold"
|
||||
onPressedChange={() => editor.chain().focus().toggleBold().run()}
|
||||
pressed={state.isBold}
|
||||
size="sm"
|
||||
title="Bold (Ctrl+B)"
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<div
|
||||
aria-label="Formatting"
|
||||
className={cn(
|
||||
'bg-muted/40 order-last flex items-center border-t px-1 py-1',
|
||||
'md:order-first md:border-t-0 md:border-b',
|
||||
disabled && 'opacity-60',
|
||||
)}
|
||||
data-slot="markdown-editor-toolbar"
|
||||
ref={toolbarRef}
|
||||
role="toolbar"
|
||||
>
|
||||
<Bold />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
aria-label="Italic"
|
||||
onPressedChange={() => editor.chain().focus().toggleItalic().run()}
|
||||
pressed={state.isItalic}
|
||||
size="sm"
|
||||
title="Italic (Ctrl+I)"
|
||||
>
|
||||
<Italic />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
aria-label="Strikethrough"
|
||||
onPressedChange={() => editor.chain().focus().toggleStrike().run()}
|
||||
pressed={state.isStrike}
|
||||
size="sm"
|
||||
title="Strikethrough"
|
||||
>
|
||||
<Strikethrough />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
aria-label="Inline code"
|
||||
onPressedChange={() => editor.chain().focus().toggleCode().run()}
|
||||
pressed={state.isCode}
|
||||
size="sm"
|
||||
title="Inline code"
|
||||
>
|
||||
<Code />
|
||||
</Toggle>
|
||||
|
||||
<Separator
|
||||
className="mx-1 h-5"
|
||||
orientation="vertical"
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
aria-label="Heading 1"
|
||||
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
pressed={state.isH1}
|
||||
size="sm"
|
||||
title="Heading 1"
|
||||
>
|
||||
<Heading1 />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
aria-label="Heading 2"
|
||||
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
pressed={state.isH2}
|
||||
size="sm"
|
||||
title="Heading 2"
|
||||
>
|
||||
<Heading2 />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
aria-label="Heading 3"
|
||||
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
pressed={state.isH3}
|
||||
size="sm"
|
||||
title="Heading 3"
|
||||
>
|
||||
<Heading3 />
|
||||
</Toggle>
|
||||
|
||||
<Separator
|
||||
className="mx-1 h-5"
|
||||
orientation="vertical"
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
aria-label="Bullet list"
|
||||
onPressedChange={() => editor.chain().focus().toggleBulletList().run()}
|
||||
pressed={state.isBulletList}
|
||||
size="sm"
|
||||
title="Bullet list"
|
||||
>
|
||||
<List />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
aria-label="Ordered list"
|
||||
onPressedChange={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
pressed={state.isOrderedList}
|
||||
size="sm"
|
||||
title="Ordered list"
|
||||
>
|
||||
<ListOrdered />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
aria-label="Task list"
|
||||
onPressedChange={() => editor.chain().focus().toggleTaskList().run()}
|
||||
pressed={state.isTaskList}
|
||||
size="sm"
|
||||
title="Task list"
|
||||
>
|
||||
<ListTodo />
|
||||
</Toggle>
|
||||
|
||||
<Separator
|
||||
className="mx-1 h-5"
|
||||
orientation="vertical"
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
aria-label="Blockquote"
|
||||
onPressedChange={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
pressed={state.isBlockquote}
|
||||
size="sm"
|
||||
title="Blockquote"
|
||||
>
|
||||
<Quote />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
aria-label="Code block"
|
||||
onPressedChange={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
pressed={state.isCodeBlock}
|
||||
size="sm"
|
||||
title="Code block"
|
||||
>
|
||||
<Code2 />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
aria-label="Link"
|
||||
onPressedChange={handleSetLink}
|
||||
pressed={state.isLink}
|
||||
size="sm"
|
||||
title="Insert link"
|
||||
>
|
||||
<LinkIcon />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
aria-label="Insert image"
|
||||
onPressedChange={() => {
|
||||
const url = window.prompt('Image URL');
|
||||
|
||||
if (url && isSafeImageSrc(url)) {
|
||||
editor.chain().focus().setImage({ src: url }).run();
|
||||
}
|
||||
}}
|
||||
pressed={false}
|
||||
size="sm"
|
||||
title="Insert image"
|
||||
>
|
||||
<ImagePlus />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
aria-label="Horizontal rule"
|
||||
onPressedChange={() => editor.chain().focus().setHorizontalRule().run()}
|
||||
pressed={false}
|
||||
size="sm"
|
||||
title="Horizontal rule"
|
||||
>
|
||||
<Minus />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
aria-label="Insert table"
|
||||
onPressedChange={() =>
|
||||
editor.chain().focus().insertTable({ cols: 3, rows: 3, withHeaderRow: true }).run()
|
||||
}
|
||||
pressed={state.isTable}
|
||||
size="sm"
|
||||
title="Insert table"
|
||||
>
|
||||
<Table />
|
||||
</Toggle>
|
||||
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<Toggle
|
||||
aria-label="Undo"
|
||||
disabled={!state.canUndo}
|
||||
onPressedChange={() => editor.chain().focus().undo().run()}
|
||||
pressed={false}
|
||||
size="sm"
|
||||
title="Undo (Ctrl+Z)"
|
||||
{/* Scroll strip: controls never wrap — they scroll horizontally. min-w-0 lets this flex child
|
||||
shrink below its content so the overflow actually engages; Undo/Redo live OUTSIDE it (below) so
|
||||
they stay pinned right instead of scrolling away (ml-auto collapses to 0 once a flex row overflows). */}
|
||||
<div
|
||||
className="flex min-w-0 flex-1 [scrollbar-width:none] items-center gap-0.5 overflow-x-auto overscroll-x-contain [&::-webkit-scrollbar]:hidden [&>*]:shrink-0"
|
||||
ref={stripRef}
|
||||
>
|
||||
<Undo />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
aria-label="Redo"
|
||||
disabled={!state.canRedo}
|
||||
onPressedChange={() => editor.chain().focus().redo().run()}
|
||||
pressed={false}
|
||||
size="sm"
|
||||
title="Redo (Ctrl+Shift+Z)"
|
||||
<HeadingMenu
|
||||
activeLevel={state.headingLevel}
|
||||
disabled={disabled}
|
||||
editor={editor}
|
||||
/>
|
||||
|
||||
<Separator
|
||||
className="mx-1 h-5"
|
||||
orientation="vertical"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-0.5"
|
||||
role="group"
|
||||
>
|
||||
<ToolbarToggle
|
||||
disabled={disabled}
|
||||
label="Bold"
|
||||
onPressedChange={() => editor.chain().focus().toggleBold().run()}
|
||||
pressed={state.isBold}
|
||||
shortcut="⌘B"
|
||||
>
|
||||
<Bold />
|
||||
</ToolbarToggle>
|
||||
<ToolbarToggle
|
||||
disabled={disabled}
|
||||
label="Italic"
|
||||
onPressedChange={() => editor.chain().focus().toggleItalic().run()}
|
||||
pressed={state.isItalic}
|
||||
shortcut="⌘I"
|
||||
>
|
||||
<Italic />
|
||||
</ToolbarToggle>
|
||||
<ToolbarToggle
|
||||
disabled={disabled}
|
||||
label="Strikethrough"
|
||||
onPressedChange={() => editor.chain().focus().toggleStrike().run()}
|
||||
pressed={state.isStrike}
|
||||
>
|
||||
<Strikethrough />
|
||||
</ToolbarToggle>
|
||||
<ToolbarToggle
|
||||
disabled={disabled}
|
||||
label="Inline code"
|
||||
onPressedChange={() => editor.chain().focus().toggleCode().run()}
|
||||
pressed={state.isCode}
|
||||
>
|
||||
<Code />
|
||||
</ToolbarToggle>
|
||||
<LinkPopover
|
||||
disabled={disabled}
|
||||
editor={editor}
|
||||
isActive={state.isLink}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator
|
||||
className="mx-1 h-5"
|
||||
orientation="vertical"
|
||||
/>
|
||||
|
||||
<ListMenu
|
||||
activeType={state.activeListType}
|
||||
disabled={disabled}
|
||||
editor={editor}
|
||||
/>
|
||||
|
||||
<Separator
|
||||
className="mx-1 h-5"
|
||||
orientation="vertical"
|
||||
/>
|
||||
|
||||
<TableMenu
|
||||
columnAlign={state.columnAlign}
|
||||
disabled={disabled}
|
||||
editor={editor}
|
||||
isActive={state.isTable}
|
||||
isHeaderRow={state.isHeaderRow}
|
||||
/>
|
||||
|
||||
<Separator
|
||||
className="mx-1 h-5"
|
||||
orientation="vertical"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-0.5"
|
||||
role="group"
|
||||
>
|
||||
<ToolbarToggle
|
||||
disabled={disabled}
|
||||
label="Blockquote"
|
||||
onPressedChange={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
pressed={state.isBlockquote}
|
||||
>
|
||||
<Quote />
|
||||
</ToolbarToggle>
|
||||
<ToolbarToggle
|
||||
disabled={disabled}
|
||||
label="Code block"
|
||||
onPressedChange={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
pressed={state.isCodeBlock}
|
||||
>
|
||||
<SquareCode />
|
||||
</ToolbarToggle>
|
||||
<ImagePopover
|
||||
disabled={disabled}
|
||||
editor={editor}
|
||||
/>
|
||||
<ToolbarButton
|
||||
disabled={disabled}
|
||||
label="Horizontal rule"
|
||||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||
>
|
||||
<Minus />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
|
||||
<Separator
|
||||
className="mx-1 h-5"
|
||||
orientation="vertical"
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
disabled={disabled}
|
||||
label="Clear formatting"
|
||||
onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}
|
||||
>
|
||||
<RemoveFormatting />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
|
||||
<Separator
|
||||
className="mx-1 h-5"
|
||||
orientation="vertical"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-0.5"
|
||||
role="group"
|
||||
>
|
||||
<Redo />
|
||||
</Toggle>
|
||||
<ToolbarButton
|
||||
disabled={disabled || !state.canUndo}
|
||||
label="Undo"
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
shortcut="⌘Z"
|
||||
>
|
||||
<Undo />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
disabled={disabled || !state.canRedo}
|
||||
label="Redo"
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
shortcut="⇧⌘Z"
|
||||
>
|
||||
<Redo />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -10,7 +10,10 @@ import { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { createMarkdownExtensions } from './markdown-editor-extensions';
|
||||
import { ImageHandle } from './markdown-editor-image-handle';
|
||||
import { LinkHandle } from './markdown-editor-link-handle';
|
||||
import { MARKDOWN_EDITOR_WRAPPER_CLASS as WRAPPER_CLASS } from './markdown-editor-styles';
|
||||
import { TableHandles } from './markdown-editor-table-handles';
|
||||
import { MarkdownEditorToolbar } from './markdown-editor-toolbar';
|
||||
import { findVariableOccurrences } from './markdown-editor-variable-highlight';
|
||||
import { nextVariableRange } from './markdown-editor-variable-syntax';
|
||||
@@ -33,6 +36,16 @@ interface MarkdownEditorProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface UseMarkdownEditorOptions extends Pick<AriaAttributes, 'aria-describedby' | 'aria-invalid'> {
|
||||
disabled?: boolean;
|
||||
handleRef?: Ref<MarkdownEditorHandle>;
|
||||
id?: string;
|
||||
onBlur?: () => void;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const resetUndoHistory = (editor: Editor): void => {
|
||||
const { state, view } = editor;
|
||||
// A fresh history() shares prosemirror-history's module-level singleton PluginKey, so match the
|
||||
@@ -77,10 +90,81 @@ function MarkdownEditor({
|
||||
id,
|
||||
onBlur,
|
||||
onChange,
|
||||
placeholder = 'Write something…',
|
||||
placeholder,
|
||||
ref,
|
||||
value,
|
||||
}: MarkdownEditorProps & { ref?: Ref<MarkdownEditorHandle> }) {
|
||||
const editor = useMarkdownEditor({
|
||||
'aria-describedby': ariaDescribedby,
|
||||
'aria-invalid': ariaInvalid,
|
||||
disabled,
|
||||
handleRef: ref,
|
||||
id,
|
||||
onBlur,
|
||||
onChange,
|
||||
placeholder,
|
||||
value,
|
||||
});
|
||||
|
||||
if (!editor) {
|
||||
return (
|
||||
<div
|
||||
aria-busy="true"
|
||||
className={cn(
|
||||
WRAPPER_CLASS,
|
||||
'items-center justify-center',
|
||||
disabled && 'pointer-events-none opacity-60',
|
||||
className,
|
||||
)}
|
||||
data-slot="markdown-editor"
|
||||
>
|
||||
<Loader2 className="text-muted-foreground size-5 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
WRAPPER_CLASS,
|
||||
'focus-within:ring-ring focus-within:ring-1',
|
||||
disabled && 'pointer-events-none opacity-60',
|
||||
className,
|
||||
)}
|
||||
data-slot="markdown-editor"
|
||||
>
|
||||
<MarkdownEditorToolbar
|
||||
disabled={disabled}
|
||||
editor={editor}
|
||||
/>
|
||||
<EditorContent
|
||||
className={cn(
|
||||
'prose prose-sm dark:prose-invert tiptap-content max-w-none min-w-0 flex-1 overflow-auto px-3 py-2',
|
||||
'[&_.ProseMirror]:min-h-full [&_.ProseMirror]:outline-none',
|
||||
)}
|
||||
editor={editor}
|
||||
/>
|
||||
{!disabled && <TableHandles editor={editor} />}
|
||||
{!disabled && <LinkHandle editor={editor} />}
|
||||
{!disabled && <ImageHandle editor={editor} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Owns the whole tiptap lifecycle for a markdown-controlled editor: instance creation, the RHF value-sync loop
|
||||
// (echo suppression + undo reset), disabled toggling, a11y-attribute forwarding, and the imperative handle. The
|
||||
// view below just renders whatever editor this returns — the lifecycle can be reworked here without touching it.
|
||||
function useMarkdownEditor({
|
||||
'aria-describedby': ariaDescribedby,
|
||||
'aria-invalid': ariaInvalid,
|
||||
disabled,
|
||||
handleRef,
|
||||
id,
|
||||
onBlur,
|
||||
onChange,
|
||||
placeholder = 'Write something…',
|
||||
value,
|
||||
}: UseMarkdownEditorOptions): Editor | null {
|
||||
// Suppress echoes of our own output: the markdown round-trip re-serializes slightly (whitespace/list
|
||||
// markers/blank lines), and those normalizations must not flip RHF's isDirty as if the user had edited.
|
||||
const lastEmittedRef = useRef<string>(value);
|
||||
@@ -131,7 +215,7 @@ function MarkdownEditor({
|
||||
});
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
handleRef,
|
||||
() => ({
|
||||
focus: () => {
|
||||
editor?.commands.focus();
|
||||
@@ -246,46 +330,7 @@ function MarkdownEditor({
|
||||
}
|
||||
}, [editor, ariaDescribedby, ariaInvalid, id]);
|
||||
|
||||
if (!editor) {
|
||||
return (
|
||||
<div
|
||||
aria-busy="true"
|
||||
className={cn(
|
||||
WRAPPER_CLASS,
|
||||
'items-center justify-center',
|
||||
disabled && 'pointer-events-none opacity-60',
|
||||
className,
|
||||
)}
|
||||
data-slot="markdown-editor"
|
||||
>
|
||||
<Loader2 className="text-muted-foreground size-5 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
WRAPPER_CLASS,
|
||||
'focus-within:ring-ring focus-within:ring-1',
|
||||
disabled && 'pointer-events-none opacity-60',
|
||||
className,
|
||||
)}
|
||||
data-slot="markdown-editor"
|
||||
>
|
||||
<MarkdownEditorToolbar
|
||||
disabled={disabled}
|
||||
editor={editor}
|
||||
/>
|
||||
<EditorContent
|
||||
className={cn(
|
||||
'prose prose-sm dark:prose-invert tiptap-content max-w-none min-w-0 flex-1 overflow-auto px-3 py-2',
|
||||
'[&_.ProseMirror]:min-h-full [&_.ProseMirror]:outline-none',
|
||||
)}
|
||||
editor={editor}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return editor;
|
||||
}
|
||||
|
||||
export { MarkdownEditor };
|
||||
|
||||
@@ -81,7 +81,7 @@ function SelectScrollDownButton({
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<ChevronDown className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
@@ -117,7 +117,7 @@ function SelectTrigger({ children, className, ...props }: React.ComponentProps<t
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
<ChevronDown className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user