feat: bilingual support (FA/EN), IRANSans typography, RTL layout, and interactive product detail pages
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
# Components
|
||||
|
||||
`@kits/components` holds the larger building blocks. Today that is a rich-text editor at `@kits/components/editor`.
|
||||
|
||||
To share an editor, give it `doc` and `field`, which name a field on a `@kits/sync` document; the SYNC skill explains documents. The other props are the same either way. Buttons, inputs and dialogs come from `@kits/shadcn`, and ClickUp data and its UI come from `@kits/clickup`.
|
||||
|
||||
## Editor
|
||||
|
||||
The `Editor` edits rich text with bold, italic, strike, code, links, headings, lists, tasks, quotes and code blocks. People type `/` for a block menu and `@` to mention someone. Markdown shortcuts like `**bold**`, `# `, `- ` and `[ ] ` work as they type, and a formatting bubble appears over a selection. When the editor is shared, everyone's edits merge live and each person's caret shows in their avatar colour.
|
||||
|
||||
```jsx
|
||||
import { Editor, richText, sharedRichText } from "@kits/components/editor";
|
||||
import { sharedText, useSharedCollection } from "@kits/sync";
|
||||
|
||||
<Editor value={notes} onChange={setNotes} placeholder="Notes…" />; // A local editor holds its Markdown in value and calls onChange with Markdown after every edit.
|
||||
cards.add({ title: sharedText("New card"), body: sharedRichText("# Notes") }); // To share, first create the field together with its document; the starting Markdown is optional.
|
||||
<Editor doc={`cards/${card.id}`} field="body" placeholder="Details…" />; // Then render the editor on that field. It stays read-only until the field exists or while the viewer cannot edit.
|
||||
|
||||
<Editor doc="notes/main" field="body" toolbar="fixed" className="prose dark:prose-invert min-h-[60vh]" />; // A whole document gets a fixed toolbar and prose typography.
|
||||
<Editor doc={`drafts/${me.user.id}`} field="text" single placeholder="Message…" onSubmit={send} />; // A chat composer keeps one paragraph. Enter calls onSubmit and Shift+Enter breaks the line.
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- To show rich text without editing, render `<Editor doc="…" field="…" readOnly toolbar={false} />`.
|
||||
- To get the plain text, for search or a summary, call `richText(doc, field).text()`. Do not read `card.body` directly; it is not text.
|
||||
- To start a field with content, pass it to the marker: `body: sharedRichText("# Notes")`. `richText(doc, field).setMarkdown(md)` replaces the content later.
|
||||
- Cmd+Z inside an editor undoes typing in that editor. `useUndo()` undoes your other document writes; it does not undo what `setMarkdown()` wrote.
|
||||
- For custom blocks, pass `extensions`; the `.d.ts` shows how.
|
||||
|
||||
The full API is in `kits/components/docs/editor.d.ts`.
|
||||
Vendored
+108
@@ -0,0 +1,108 @@
|
||||
import type { FieldValue } from "@kits/sync";
|
||||
import type { AnyExtension, Editor as TiptapEditor } from "@tiptap/core";
|
||||
import type { BubbleMenuPluginProps } from "@tiptap/extension-bubble-menu";
|
||||
import type { FloatingMenuPluginProps } from "@tiptap/extension-floating-menu";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
|
||||
export type { Editor as TiptapEditor, JSONContent } from "@tiptap/core";
|
||||
export { createBlockMarkdownSpec, createInlineMarkdownSpec, Extension, Mark, mergeAttributes, Node, } from "@tiptap/core";
|
||||
export { EditorContent, NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer, useEditorState, } from "@tiptap/react";
|
||||
|
||||
/** Own the state with `value`/`onChange`, or share it with `doc`/`field` on a `@kits/sync` document. */
|
||||
interface LocalBinding<T> {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
doc?: never;
|
||||
field?: never;
|
||||
}
|
||||
interface SharedBinding {
|
||||
/** The document path, like `cards/${card.id}`. */
|
||||
doc: string;
|
||||
field: string;
|
||||
value?: never;
|
||||
onChange?: never;
|
||||
}
|
||||
|
||||
export interface MentionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
export type MentionSource = MentionItem[] | ((query: string) => MentionItem[] | Promise<MentionItem[]>);
|
||||
/** An entry of the `/` menu; `run` gets the editor with the slash and query already deleted. */
|
||||
export interface SlashItem {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Extra search words, space-separated. */
|
||||
keywords?: string;
|
||||
/** One line under the label. */
|
||||
description?: string;
|
||||
run: (editor: TiptapEditor) => void;
|
||||
}
|
||||
export interface EditorOptions {
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
/** Forced on while the viewer cannot edit or the shared field is not a `sharedRichText()`. */
|
||||
readOnly?: boolean;
|
||||
/** One paragraph: Enter calls `onSubmit`, Shift+Enter is a soft break, pasted blocks fold into the line. */
|
||||
single?: boolean;
|
||||
/** Enter in `single` mode, Cmd/Ctrl+Enter otherwise. */
|
||||
onSubmit?: (editor: TiptapEditor) => void;
|
||||
/** `false` hides the `/` menu, an array adds entries to it; off in `single`. */
|
||||
slash?: false | SlashItem[];
|
||||
/** The `@` menu; default: the other people in the room when shared, none when local. */
|
||||
mentions?: MentionSource | false;
|
||||
/** Tiptap extensions appended to the kit's; every editor and `richText()` on one field must pass the same ones. A custom block (`Node.create`) needs a Markdown spec (`createBlockMarkdownSpec`) to survive `value`/`onChange` and `markdown()`, and a `/` entry via `slash`. */
|
||||
extensions?: AnyExtension[];
|
||||
onReady?: (editor: TiptapEditor) => void;
|
||||
}
|
||||
|
||||
interface EditorBaseProps extends EditorOptions {
|
||||
/** Classes on the wrapper. Content typography is yours, e.g. `prose dark:prose-invert` for a document; the editor's own chrome follows the shadcn tokens. */
|
||||
className?: string;
|
||||
/** `"bubble"` over the selection (default), `"fixed"` above the content, `false` none; `single` defaults to `false`. */
|
||||
toolbar?: "bubble" | "fixed" | false;
|
||||
/** Rendered above the content, with the editor for `BubbleMenu` / `FloatingMenu`. */
|
||||
children?: (editor: TiptapEditor) => ReactNode;
|
||||
}
|
||||
export type EditorProps = EditorBaseProps & (LocalBinding<string> | SharedBinding);
|
||||
/** Rich text with formatting, links, lists, tasks, a `/` menu and `@` mentions; `value`/`onChange` are Markdown, or bind `doc`/`field` to a `sharedRichText()` for live shared editing. */
|
||||
export declare function Editor(props: EditorProps): ReactElement;
|
||||
|
||||
export interface LocalEditorOptions extends EditorOptions {
|
||||
/** Markdown; `onChange` gets Markdown after every edit. */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
/** The editor instance over `value`/`onChange`, to render with `EditorContent`. */
|
||||
export declare function useEditor(options: LocalEditorOptions): TiptapEditor;
|
||||
/** The editor instance over a `sharedRichText()` field, to render with `EditorContent`; suspends until shared state has loaded. */
|
||||
export declare function useSharedEditor(doc: string, field: string, options?: EditorOptions): TiptapEditor;
|
||||
|
||||
/** Creates rich text everyone edits together with the `Editor`: `add({ body: sharedRichText("# Notes") })`. The Markdown is the starting content; `richText(doc, field).setMarkdown()` replaces it later. */
|
||||
export declare function sharedRichText(markdown?: string): FieldValue;
|
||||
export interface RichText {
|
||||
/** Plain text, one line per block; `""` while the field is missing or a plain value. */
|
||||
text(): string;
|
||||
html(): string;
|
||||
markdown(): string;
|
||||
/** Replaces the field's content in one write no undo reverts; a no-op while the field is missing or the viewer cannot edit. */
|
||||
setMarkdown(markdown: string): void;
|
||||
}
|
||||
/** Reads and replaces a `sharedRichText()` field outside render; pass the editor's `extensions` so both use one schema. Throws, naming the kit to use, when another kit's field is there. */
|
||||
export declare function richText(doc: string, field: string, options?: Pick<EditorOptions, "extensions">): RichText;
|
||||
|
||||
type MenuOptions<Props> = Omit<Props, "editor" | "element" | "pluginKey">;
|
||||
export interface BubbleMenuProps extends MenuOptions<BubbleMenuPluginProps> {
|
||||
editor: TiptapEditor;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
export interface FloatingMenuProps extends MenuOptions<FloatingMenuPluginProps> {
|
||||
editor: TiptapEditor;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
/** Your own menu over the selection. */
|
||||
export declare function BubbleMenu(props: BubbleMenuProps): ReactElement | null;
|
||||
/** Your own menu beside an empty line. */
|
||||
export declare function FloatingMenu(props: FloatingMenuProps): ReactElement | null;
|
||||
@@ -0,0 +1,2 @@
|
||||
export {
|
||||
} from "./index.ts";
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { OpaqueField } from "@kits/sync/internal";
|
||||
|
||||
const HELD_BY: Record<string, string> = {
|
||||
prosemirror:
|
||||
"prosemirror rich text; use <Editor> from @kits/components/editor",
|
||||
};
|
||||
|
||||
/** Returns `bound`, or throws naming the kit to use when `field` holds another kit's opaque field. */
|
||||
export function bindOpaque<T extends OpaqueField<unknown>>(
|
||||
field: string,
|
||||
bound: T,
|
||||
): T {
|
||||
const { holds } = bound;
|
||||
if (holds) {
|
||||
throw new Error(
|
||||
`"${field}" holds ${HELD_BY[holds.of] ?? `an opaque ${holds.of} field; bind it with the kit that created it`}.`,
|
||||
);
|
||||
}
|
||||
return bound;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Own the state with `value`/`onChange`, or share it with `doc`/`field` on a `@kits/sync` document. */
|
||||
export interface LocalBinding<T> {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
doc?: never;
|
||||
field?: never;
|
||||
}
|
||||
|
||||
export interface SharedBinding {
|
||||
/** The document path, like `cards/${card.id}`. */
|
||||
doc: string;
|
||||
field: string;
|
||||
value?: never;
|
||||
onChange?: never;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export {
|
||||
BubbleMenu,
|
||||
Editor,
|
||||
EditorContent,
|
||||
Extension,
|
||||
FloatingMenu,
|
||||
Mark,
|
||||
Node,
|
||||
NodeViewContent,
|
||||
NodeViewWrapper,
|
||||
ReactNodeViewRenderer,
|
||||
createBlockMarkdownSpec,
|
||||
createInlineMarkdownSpec,
|
||||
mergeAttributes,
|
||||
richText,
|
||||
sharedRichText,
|
||||
useEditor,
|
||||
useEditorState,
|
||||
useSharedEditor,
|
||||
} from "./editor/index.ts";
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Editor as TiptapEditor } from "@tiptap/core";
|
||||
import { EditorContent } from "@tiptap/react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import type { LocalBinding, SharedBinding } from "../binding";
|
||||
import type { EditorOptions } from "./options";
|
||||
import { Toolbar } from "./Toolbar";
|
||||
import { useEditor, useSharedEditor } from "./useEditor";
|
||||
|
||||
interface EditorBaseProps extends EditorOptions {
|
||||
/** Classes on the wrapper. Content typography is yours, e.g. `prose dark:prose-invert` for a document; the editor's own chrome follows the shadcn tokens. */
|
||||
className?: string;
|
||||
/** `"bubble"` over the selection (default), `"fixed"` above the content, `false` none; `single` defaults to `false`. */
|
||||
toolbar?: "bubble" | "fixed" | false;
|
||||
/** Rendered above the content, with the editor for `BubbleMenu` / `FloatingMenu`. */
|
||||
children?: (editor: TiptapEditor) => ReactNode;
|
||||
}
|
||||
|
||||
export type EditorProps = EditorBaseProps &
|
||||
(LocalBinding<string> | SharedBinding);
|
||||
|
||||
/** Rich text with formatting, links, lists, tasks, a `/` menu and `@` mentions; `value`/`onChange` are Markdown, or bind `doc`/`field` to a `sharedRichText()` for live shared editing. */
|
||||
export function Editor(props: EditorProps): ReactElement {
|
||||
if (typeof props.doc === "string") {
|
||||
return <Shared {...props} />;
|
||||
}
|
||||
if (typeof props.onChange !== "function") {
|
||||
throw new Error(
|
||||
"Editor needs value and onChange to own its state, or doc and field to share it.",
|
||||
);
|
||||
}
|
||||
return <Local {...props} />;
|
||||
}
|
||||
|
||||
function Local({
|
||||
value,
|
||||
onChange,
|
||||
...rest
|
||||
}: EditorBaseProps & LocalBinding<string>): ReactElement {
|
||||
const editor = useEditor({ ...rest, value, onChange });
|
||||
return <Surface editor={editor} {...rest} />;
|
||||
}
|
||||
|
||||
function Shared({
|
||||
doc,
|
||||
field,
|
||||
...rest
|
||||
}: EditorBaseProps & SharedBinding): ReactElement {
|
||||
const editor = useSharedEditor(doc, field, rest);
|
||||
return <Surface editor={editor} {...rest} />;
|
||||
}
|
||||
|
||||
function Surface({
|
||||
editor,
|
||||
className,
|
||||
toolbar,
|
||||
children,
|
||||
single = false,
|
||||
}: EditorBaseProps & { editor: TiptapEditor }): ReactElement {
|
||||
const placement = toolbar ?? (single ? false : "bubble");
|
||||
return (
|
||||
<div
|
||||
className={className ? `ck-editor ${className}` : "ck-editor"}
|
||||
data-toolbar={placement || undefined}
|
||||
>
|
||||
{children?.(editor)}
|
||||
{placement && (
|
||||
<Toolbar blocks={!single} editor={editor} placement={placement} />
|
||||
)}
|
||||
<EditorContent className="ck-content" editor={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { colorOf, LABEL } from "@kits/sync/internal";
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { yCursorPlugin } from "@tiptap/y-tiptap";
|
||||
import type { Awareness } from "y-protocols/awareness";
|
||||
|
||||
export interface SyncCaretOptions {
|
||||
awareness: Awareness | null;
|
||||
}
|
||||
|
||||
interface WireUser {
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
}
|
||||
|
||||
interface WireState {
|
||||
user?: WireUser;
|
||||
}
|
||||
|
||||
/** Everyone else's caret and selection in their avatar colour with their name; my other tabs are not drawn. */
|
||||
export const SyncCaret = Extension.create<SyncCaretOptions>({
|
||||
name: "syncCaret",
|
||||
|
||||
addOptions() {
|
||||
return { awareness: null };
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { awareness } = this.options;
|
||||
if (!awareness) {
|
||||
return [];
|
||||
}
|
||||
const mine = () =>
|
||||
userIdOf(
|
||||
(awareness.getLocalState() as WireState | null)?.user,
|
||||
awareness.clientID,
|
||||
);
|
||||
return [
|
||||
yCursorPlugin(awareness, {
|
||||
awarenessStateFilter: (
|
||||
_current: number,
|
||||
clientId: number,
|
||||
state: WireState | null,
|
||||
) =>
|
||||
clientId !== awareness.clientID &&
|
||||
userIdOf(state?.user, clientId) !== mine(),
|
||||
cursorBuilder: (user: WireUser, clientId: number) =>
|
||||
caretElement(userIdOf(user, clientId), user),
|
||||
selectionBuilder: (user: WireUser, clientId: number) => ({
|
||||
class: "ProseMirror-yjs-selection",
|
||||
style: `background-color: ${colorOf(userIdOf(user, clientId)).backgroundColor}${LABEL.alpha}`,
|
||||
}),
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
function userIdOf(user: WireUser | undefined, clientId: number): string {
|
||||
const id = user?.id;
|
||||
return id == null || id === "" ? `client:${clientId}` : String(id);
|
||||
}
|
||||
|
||||
function caretElement(id: string, user: WireUser): HTMLElement {
|
||||
const colors = colorOf(id);
|
||||
const caret = document.createElement("span");
|
||||
caret.className = "ProseMirror-yjs-cursor";
|
||||
caret.setAttribute("aria-hidden", "true");
|
||||
caret.dataset.syncCaret = id;
|
||||
caret.style.borderColor = colors.backgroundColor;
|
||||
const label = document.createElement("div");
|
||||
label.style.backgroundColor = colors.backgroundColor;
|
||||
label.style.color = colors.color;
|
||||
label.textContent = typeof user.name === "string" ? user.name : id;
|
||||
caret.append("\u2060", label, "\u2060");
|
||||
return caret;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import type { Editor as TiptapEditor } from "@tiptap/core";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import {
|
||||
Bold,
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Italic,
|
||||
Link,
|
||||
List,
|
||||
ListChecks,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Strikethrough,
|
||||
} from "lucide-react";
|
||||
import type { ReactElement } from "react";
|
||||
import { useState } from "react";
|
||||
import { BubbleMenu } from "./menus";
|
||||
|
||||
interface ToolbarProps {
|
||||
editor: TiptapEditor;
|
||||
placement: "bubble" | "fixed";
|
||||
/** Whether the block tools are shown. */
|
||||
blocks: boolean;
|
||||
}
|
||||
|
||||
interface Tool {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: typeof Bold;
|
||||
block?: true;
|
||||
active: (editor: TiptapEditor) => boolean;
|
||||
/** Absent only for the link tool, which opens the URL field. */
|
||||
run?: (editor: TiptapEditor) => void;
|
||||
}
|
||||
|
||||
const TOOLS: readonly Tool[] = [
|
||||
{
|
||||
id: "bold",
|
||||
label: "Bold",
|
||||
icon: Bold,
|
||||
active: (e) => e.isActive("bold"),
|
||||
run: (e) => e.chain().focus().toggleBold().run(),
|
||||
},
|
||||
{
|
||||
id: "italic",
|
||||
label: "Italic",
|
||||
icon: Italic,
|
||||
active: (e) => e.isActive("italic"),
|
||||
run: (e) => e.chain().focus().toggleItalic().run(),
|
||||
},
|
||||
{
|
||||
id: "strike",
|
||||
label: "Strikethrough",
|
||||
icon: Strikethrough,
|
||||
active: (e) => e.isActive("strike"),
|
||||
run: (e) => e.chain().focus().toggleStrike().run(),
|
||||
},
|
||||
{
|
||||
id: "code",
|
||||
label: "Code",
|
||||
icon: Code,
|
||||
active: (e) => e.isActive("code"),
|
||||
run: (e) => e.chain().focus().toggleCode().run(),
|
||||
},
|
||||
{
|
||||
id: "link",
|
||||
label: "Link",
|
||||
icon: Link,
|
||||
active: (e) => e.isActive("link"),
|
||||
},
|
||||
{
|
||||
id: "h1",
|
||||
label: "Heading 1",
|
||||
icon: Heading1,
|
||||
block: true,
|
||||
active: (e) => e.isActive("heading", { level: 1 }),
|
||||
run: (e) => e.chain().focus().toggleHeading({ level: 1 }).run(),
|
||||
},
|
||||
{
|
||||
id: "h2",
|
||||
label: "Heading 2",
|
||||
icon: Heading2,
|
||||
block: true,
|
||||
active: (e) => e.isActive("heading", { level: 2 }),
|
||||
run: (e) => e.chain().focus().toggleHeading({ level: 2 }).run(),
|
||||
},
|
||||
{
|
||||
id: "h3",
|
||||
label: "Heading 3",
|
||||
icon: Heading3,
|
||||
block: true,
|
||||
active: (e) => e.isActive("heading", { level: 3 }),
|
||||
run: (e) => e.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||
},
|
||||
{
|
||||
id: "bullets",
|
||||
label: "Bullet list",
|
||||
icon: List,
|
||||
block: true,
|
||||
active: (e) => e.isActive("bulletList"),
|
||||
run: (e) => e.chain().focus().toggleBulletList().run(),
|
||||
},
|
||||
{
|
||||
id: "numbers",
|
||||
label: "Numbered list",
|
||||
icon: ListOrdered,
|
||||
block: true,
|
||||
active: (e) => e.isActive("orderedList"),
|
||||
run: (e) => e.chain().focus().toggleOrderedList().run(),
|
||||
},
|
||||
{
|
||||
id: "tasks",
|
||||
label: "Task list",
|
||||
icon: ListChecks,
|
||||
block: true,
|
||||
active: (e) => e.isActive("taskList"),
|
||||
run: (e) => e.chain().focus().toggleTaskList().run(),
|
||||
},
|
||||
{
|
||||
id: "quote",
|
||||
label: "Quote",
|
||||
icon: Quote,
|
||||
block: true,
|
||||
active: (e) => e.isActive("blockquote"),
|
||||
run: (e) => e.chain().focus().toggleBlockquote().run(),
|
||||
},
|
||||
];
|
||||
|
||||
export function Toolbar(props: ToolbarProps): ReactElement | null {
|
||||
const { editor, placement } = props;
|
||||
const [link, setLink] = useState<string | null>(null);
|
||||
const tools = TOOLS.filter((tool) => props.blocks || !tool.block);
|
||||
const active = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor: current }) =>
|
||||
tools.map((tool) => (current.isEditable ? tool.active(current) : false)),
|
||||
});
|
||||
const editable = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor: current }) => current.isEditable,
|
||||
});
|
||||
if (!editable) {
|
||||
return null;
|
||||
}
|
||||
const close = () => {
|
||||
setLink(null);
|
||||
editor.commands.focus();
|
||||
};
|
||||
const bar =
|
||||
link === null ? (
|
||||
<div
|
||||
aria-label="Formatting"
|
||||
className="ck-toolbar"
|
||||
data-placement={placement}
|
||||
role="toolbar"
|
||||
>
|
||||
{tools.map((tool, index) => {
|
||||
const Icon = tool.icon;
|
||||
return (
|
||||
<button
|
||||
aria-label={tool.label}
|
||||
aria-pressed={active[index] ?? false}
|
||||
key={tool.id}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() =>
|
||||
tool.run
|
||||
? tool.run(editor)
|
||||
: setLink(
|
||||
(editor.getAttributes("link").href as
|
||||
| string
|
||||
| undefined) ?? "",
|
||||
)
|
||||
}
|
||||
title={tool.label}
|
||||
type="button"
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
className="ck-toolbar"
|
||||
data-placement={placement}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
applyLink(editor, link);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label="Link URL"
|
||||
autoFocus
|
||||
onChange={(event) => setLink(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
close();
|
||||
}
|
||||
}}
|
||||
inputMode="url"
|
||||
placeholder="https://"
|
||||
type="text"
|
||||
value={link}
|
||||
/>
|
||||
<button aria-label="Apply link" title="Apply link" type="submit">
|
||||
<Link aria-hidden="true" />
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
return placement === "bubble" ? (
|
||||
<BubbleMenu editor={editor} options={{ placement: "top", offset: 8 }}>
|
||||
{bar}
|
||||
</BubbleMenu>
|
||||
) : (
|
||||
bar
|
||||
);
|
||||
}
|
||||
|
||||
function applyLink(editor: TiptapEditor, href: string): void {
|
||||
const chain = editor.chain().focus().extendMarkRange("link");
|
||||
if (href.trim() === "") {
|
||||
chain.unsetLink().run();
|
||||
} else {
|
||||
chain.setLink({ href: href.trim() }).run();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { shared } from "@kits/sync";
|
||||
import type { OpaqueField } from "@kits/sync/internal";
|
||||
import {
|
||||
Extension,
|
||||
type AnyExtension,
|
||||
type Editor as TiptapEditor,
|
||||
} from "@tiptap/core";
|
||||
import Collaboration from "@tiptap/extension-collaboration";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { TaskItem, TaskList } from "@tiptap/extension-list";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import { TableKit } from "@tiptap/extension-table";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Fragment, Slice, type Node as PMNode } from "@tiptap/pm/model";
|
||||
import { Placeholder } from "@tiptap/extensions";
|
||||
import { Markdown } from "@tiptap/markdown";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import type * as Y from "yjs";
|
||||
import type { EditorOptions, MentionItem, MentionSource } from "./options";
|
||||
import { Slash } from "./slash";
|
||||
import { menuRenderer, popupOptions } from "./suggestions";
|
||||
import { SyncCaret } from "./SyncCaret";
|
||||
|
||||
/** The kit's extensions; every editor and `richText()` reader registers the same nodes and marks, whatever the options. */
|
||||
export function editorExtensions(
|
||||
options: EditorOptions,
|
||||
field?: OpaqueField<Y.XmlFragment>,
|
||||
): AnyExtension[] {
|
||||
const { single = false } = options;
|
||||
const target = field?.value;
|
||||
const extensions: AnyExtension[] = [
|
||||
StarterKit.configure({
|
||||
link: { openOnClick: false },
|
||||
undoRedo: target ? false : undefined,
|
||||
trailingNode: false,
|
||||
}),
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
Image,
|
||||
TableKit.configure({ table: { resizable: false } }),
|
||||
mentionExtension(options.mentions ?? (target ? collaborators : false)),
|
||||
Markdown,
|
||||
];
|
||||
if (options.placeholder) {
|
||||
extensions.push(
|
||||
Placeholder.configure({ placeholder: options.placeholder }),
|
||||
);
|
||||
}
|
||||
if (options.onSubmit || single) {
|
||||
extensions.push(Submit.configure({ single, onSubmit: options.onSubmit }));
|
||||
}
|
||||
if (single) {
|
||||
extensions.push(SingleLine);
|
||||
}
|
||||
const slash = options.slash ?? !single;
|
||||
if (slash) {
|
||||
extensions.push(
|
||||
Slash.configure({
|
||||
items: Array.isArray(slash) ? slash : [],
|
||||
blocks: !single,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (target) {
|
||||
extensions.push(
|
||||
Collaboration.configure({ fragment: target }),
|
||||
SyncCaret.configure({ awareness: field?.awareness ?? null }),
|
||||
);
|
||||
}
|
||||
return [...extensions, ...(options.extensions ?? [])];
|
||||
}
|
||||
|
||||
interface SubmitOptions {
|
||||
single: boolean;
|
||||
onSubmit: ((editor: TiptapEditor) => void) | undefined;
|
||||
}
|
||||
|
||||
const Submit = Extension.create<SubmitOptions>({
|
||||
name: "submit",
|
||||
priority: 1000,
|
||||
|
||||
addOptions() {
|
||||
return { single: false, onSubmit: undefined };
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
const submit = () => {
|
||||
this.options.onSubmit?.(this.editor);
|
||||
return true;
|
||||
};
|
||||
const shortcuts: Record<string, () => boolean> = {};
|
||||
if (this.options.onSubmit) {
|
||||
shortcuts["Mod-Enter"] = submit;
|
||||
}
|
||||
if (this.options.single) {
|
||||
shortcuts.Enter = submit;
|
||||
}
|
||||
return shortcuts;
|
||||
},
|
||||
});
|
||||
|
||||
/** Folds pasted blocks into one paragraph, a soft break between them. */
|
||||
const SingleLine = Extension.create({
|
||||
name: "singleLine",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { schema } = this.editor;
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
transformPasted: (slice) => {
|
||||
const lines: Fragment[] = [];
|
||||
slice.content.descendants((node: PMNode) => {
|
||||
if (node.isTextblock) {
|
||||
lines.push(node.content);
|
||||
}
|
||||
return !node.isTextblock;
|
||||
});
|
||||
if (lines.length < 2) {
|
||||
return slice;
|
||||
}
|
||||
const inline = lines.reduce((joined, line) =>
|
||||
joined
|
||||
.append(Fragment.from(schema.nodes.hardBreak!.create()))
|
||||
.append(line),
|
||||
);
|
||||
return new Slice(
|
||||
Fragment.from(schema.nodes.paragraph!.create(null, inline)),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
/** The `@` menu over `source`; with `false` the mention node stays in the schema but the menu never opens. */
|
||||
function mentionExtension(source: MentionSource | false): AnyExtension {
|
||||
const key = new PluginKey("mention");
|
||||
return Mention.extend({
|
||||
renderMarkdown: ({ attrs }) => `@${attrs?.label ?? attrs?.id ?? ""}`,
|
||||
}).configure({
|
||||
HTMLAttributes: { class: "mention" },
|
||||
renderText: ({ node }) => `@${node.attrs.label ?? node.attrs.id}`,
|
||||
suggestion: {
|
||||
...popupOptions,
|
||||
char: "@",
|
||||
pluginKey: key,
|
||||
allow: () => source !== false,
|
||||
items: ({ query }) =>
|
||||
typeof source === "function"
|
||||
? source(query)
|
||||
: matching(source || [], query),
|
||||
render: menuRenderer<MentionItem>(key, "mention"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const collaborators: MentionSource = (query) =>
|
||||
matching(
|
||||
shared.collaborators.map(({ user }) => ({ id: user.id, label: user.name })),
|
||||
query,
|
||||
);
|
||||
|
||||
export function matching<Item extends { label: string }>(
|
||||
items: Item[],
|
||||
query: string,
|
||||
): Item[] {
|
||||
const wanted = query.trim().toLowerCase();
|
||||
return items
|
||||
.filter((item) => item.label.toLowerCase().includes(wanted))
|
||||
.slice(0, 8);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export type { EditorProps } from "./Editor";
|
||||
export { Editor } from "./Editor";
|
||||
export type {
|
||||
EditorOptions,
|
||||
MentionItem,
|
||||
MentionSource,
|
||||
SlashItem,
|
||||
} from "./options";
|
||||
export type { LocalEditorOptions } from "./useEditor";
|
||||
export { useEditor, useSharedEditor } from "./useEditor";
|
||||
export type { RichText } from "./richText";
|
||||
export { richText, sharedRichText } from "./richText";
|
||||
export type { BubbleMenuProps, FloatingMenuProps } from "./menus";
|
||||
export { BubbleMenu, FloatingMenu } from "./menus";
|
||||
export type { Editor as TiptapEditor, JSONContent } from "@tiptap/core";
|
||||
export {
|
||||
createBlockMarkdownSpec,
|
||||
createInlineMarkdownSpec,
|
||||
Extension,
|
||||
Mark,
|
||||
mergeAttributes,
|
||||
Node,
|
||||
} from "@tiptap/core";
|
||||
export {
|
||||
EditorContent,
|
||||
NodeViewContent,
|
||||
NodeViewWrapper,
|
||||
ReactNodeViewRenderer,
|
||||
useEditorState,
|
||||
} from "@tiptap/react";
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Editor as TiptapEditor } from "@tiptap/core";
|
||||
import type { BubbleMenuPluginProps } from "@tiptap/extension-bubble-menu";
|
||||
import { BubbleMenuPlugin } from "@tiptap/extension-bubble-menu";
|
||||
import type { FloatingMenuPluginProps } from "@tiptap/extension-floating-menu";
|
||||
import { FloatingMenuPlugin } from "@tiptap/extension-floating-menu";
|
||||
import { PluginKey, type Plugin } from "@tiptap/pm/state";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { useEffect, useLayoutEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
type MenuOptions<Props> = Omit<Props, "editor" | "element" | "pluginKey">;
|
||||
|
||||
export interface BubbleMenuProps extends MenuOptions<BubbleMenuPluginProps> {
|
||||
editor: TiptapEditor;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export interface FloatingMenuProps extends MenuOptions<FloatingMenuPluginProps> {
|
||||
editor: TiptapEditor;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Your own menu over the selection. */
|
||||
export function BubbleMenu(props: BubbleMenuProps): ReactElement | null {
|
||||
const { editor, className, children, ...options } = props;
|
||||
const element = useMenuElement(editor, className, (menu) =>
|
||||
BubbleMenuPlugin({
|
||||
...options,
|
||||
shouldShow: options.shouldShow ?? null,
|
||||
editor,
|
||||
element: menu,
|
||||
pluginKey: new PluginKey("bubbleMenu"),
|
||||
}),
|
||||
);
|
||||
return element ? createPortal(children, element) : null;
|
||||
}
|
||||
|
||||
/** Your own menu beside an empty line. */
|
||||
export function FloatingMenu(props: FloatingMenuProps): ReactElement | null {
|
||||
const { editor, className, children, ...options } = props;
|
||||
const element = useMenuElement(editor, className, (menu) =>
|
||||
FloatingMenuPlugin({
|
||||
...options,
|
||||
shouldShow: options.shouldShow ?? null,
|
||||
editor,
|
||||
element: menu,
|
||||
pluginKey: new PluginKey("floatingMenu"),
|
||||
}),
|
||||
);
|
||||
return element ? createPortal(children, element) : null;
|
||||
}
|
||||
|
||||
/** A detached element the plugin positions, registered with the editor while mounted. */
|
||||
function useMenuElement(
|
||||
editor: TiptapEditor,
|
||||
className: string | undefined,
|
||||
plugin: (element: HTMLElement) => Plugin,
|
||||
): HTMLElement | undefined {
|
||||
const [element, setElement] = useState<HTMLElement>();
|
||||
useLayoutEffect(() => {
|
||||
const menu = document.createElement("div");
|
||||
menu.style.visibility = "hidden";
|
||||
menu.style.position = "absolute";
|
||||
setElement(menu);
|
||||
return () => setElement(undefined);
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
if (element) {
|
||||
element.className = className ?? "";
|
||||
}
|
||||
}, [element, className]);
|
||||
useEffect(() => {
|
||||
if (!element || editor.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
const registered = plugin(element);
|
||||
editor.registerPlugin(registered);
|
||||
return () => {
|
||||
if (!editor.isDestroyed) {
|
||||
editor.unregisterPlugin(registered.spec.key!);
|
||||
}
|
||||
element.remove();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- the plugin takes its options once, when registered.
|
||||
}, [editor, element]);
|
||||
return element;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { AnyExtension, Editor as TiptapEditor } from "@tiptap/core";
|
||||
|
||||
export interface MentionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type MentionSource =
|
||||
| MentionItem[]
|
||||
| ((query: string) => MentionItem[] | Promise<MentionItem[]>);
|
||||
|
||||
/** An entry of the `/` menu; `run` gets the editor with the slash and query already deleted. */
|
||||
export interface SlashItem {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Extra search words, space-separated. */
|
||||
keywords?: string;
|
||||
/** One line under the label. */
|
||||
description?: string;
|
||||
run: (editor: TiptapEditor) => void;
|
||||
}
|
||||
|
||||
export interface EditorOptions {
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
/** Forced on while the viewer cannot edit or the shared field is not a `sharedRichText()`. */
|
||||
readOnly?: boolean;
|
||||
/** One paragraph: Enter calls `onSubmit`, Shift+Enter is a soft break, pasted blocks fold into the line. */
|
||||
single?: boolean;
|
||||
/** Enter in `single` mode, Cmd/Ctrl+Enter otherwise. */
|
||||
onSubmit?: (editor: TiptapEditor) => void;
|
||||
/** `false` hides the `/` menu, an array adds entries to it; off in `single`. */
|
||||
slash?: false | SlashItem[];
|
||||
/** The `@` menu; default: the other people in the room when shared, none when local. */
|
||||
mentions?: MentionSource | false;
|
||||
/** Tiptap extensions appended to the kit's; every editor and `richText()` on one field must pass the same ones. A custom block (`Node.create`) needs a Markdown spec (`createBlockMarkdownSpec`) to survive `value`/`onChange` and `markdown()`, and a `/` entry via `slash`. */
|
||||
extensions?: AnyExtension[];
|
||||
onReady?: (editor: TiptapEditor) => void;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type * as Y from "yjs";
|
||||
|
||||
type XmlNode = Y.XmlElement | Y.XmlText | Y.XmlHook;
|
||||
|
||||
/** One line per block, formatting dropped; an empty inline element is a soft break. */
|
||||
export function plainTextOf(fragment: Y.XmlFragment): string {
|
||||
return fragment.toArray().map(blockTextOf).join("\n");
|
||||
}
|
||||
|
||||
function blockTextOf(node: XmlNode): string {
|
||||
if (isText(node)) {
|
||||
return inlineTextOf(node);
|
||||
}
|
||||
if (!isElement(node)) {
|
||||
return "";
|
||||
}
|
||||
const children = node.toArray() as XmlNode[];
|
||||
if (!children.some(isText)) {
|
||||
return children.map(blockTextOf).join("\n");
|
||||
}
|
||||
return children
|
||||
.map((child) => {
|
||||
if (isText(child)) {
|
||||
return inlineTextOf(child);
|
||||
}
|
||||
if (!isElement(child)) {
|
||||
return "";
|
||||
}
|
||||
const empty =
|
||||
child.length === 0 && Object.keys(child.getAttributes()).length === 0;
|
||||
return empty ? "\n" : blockTextOf(child);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function inlineTextOf(text: Y.XmlText): string {
|
||||
return (text.toDelta() as Array<{ insert: unknown }>)
|
||||
.map((op) => (typeof op.insert === "string" ? op.insert : ""))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function isText(node: XmlNode): node is Y.XmlText {
|
||||
return "toDelta" in node;
|
||||
}
|
||||
|
||||
function isElement(node: XmlNode): node is Y.XmlElement {
|
||||
return "nodeName" in node;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { FieldValue, JsonValue } from "@kits/sync";
|
||||
import {
|
||||
opaque,
|
||||
opaqueField,
|
||||
type OpaqueNode,
|
||||
type OpaqueTextRun,
|
||||
} from "@kits/sync/internal";
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { Editor as TiptapEditor, getHTMLFromFragment } from "@tiptap/core";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
prosemirrorJSONToYXmlFragment,
|
||||
yXmlFragmentToProsemirrorJSON,
|
||||
} from "@tiptap/y-tiptap";
|
||||
import { bindOpaque } from "../bindOpaque";
|
||||
import { editorExtensions } from "./extensions";
|
||||
import type { EditorOptions } from "./options";
|
||||
import { plainTextOf } from "./plainText";
|
||||
|
||||
/** Creates rich text everyone edits together with the `Editor`: `add({ body: sharedRichText("# Notes") })`. The Markdown is the starting content; `richText(doc, field).setMarkdown()` replaces it later. */
|
||||
export function sharedRichText(markdown?: string): FieldValue {
|
||||
if (markdown !== undefined && typeof markdown !== "string") {
|
||||
throw new Error(
|
||||
`sharedRichText() expects Markdown to start with, got ${typeof markdown}.`,
|
||||
);
|
||||
}
|
||||
if (!markdown) {
|
||||
return opaque("xml", "prosemirror");
|
||||
}
|
||||
return opaque(
|
||||
"xml",
|
||||
"prosemirror",
|
||||
withEditor({}, (editor) =>
|
||||
nodesOf(PMNode.fromJSON(editor.schema, editor.markdown!.parse(markdown))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function nodesOf(parent: PMNode): OpaqueNode[] {
|
||||
const nodes: OpaqueNode[] = [];
|
||||
let runs: OpaqueTextRun[] | undefined;
|
||||
parent.forEach((child) => {
|
||||
if (child.isText) {
|
||||
if (!runs) {
|
||||
runs = [];
|
||||
nodes.push({ text: runs });
|
||||
}
|
||||
runs.push(runOf(child));
|
||||
} else {
|
||||
runs = undefined;
|
||||
nodes.push(elementOf(child));
|
||||
}
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function elementOf(node: PMNode): OpaqueNode {
|
||||
const attrs: Record<string, JsonValue> = {};
|
||||
for (const [name, value] of Object.entries(node.attrs)) {
|
||||
if (value !== null) {
|
||||
attrs[name] = value as JsonValue;
|
||||
}
|
||||
}
|
||||
return { type: node.type.name, attrs, children: nodesOf(node) };
|
||||
}
|
||||
|
||||
function runOf(node: PMNode): OpaqueTextRun {
|
||||
const attributes: Record<string, JsonValue> = {};
|
||||
for (const mark of node.marks) {
|
||||
attributes[mark.type.name] = mark.attrs as Record<string, JsonValue>;
|
||||
}
|
||||
return { insert: node.text!, attributes };
|
||||
}
|
||||
|
||||
function withEditor<T>(
|
||||
options: Pick<EditorOptions, "extensions">,
|
||||
use: (editor: TiptapEditor) => T,
|
||||
): T {
|
||||
const editor = new TiptapEditor({
|
||||
element: null,
|
||||
extensions: editorExtensions(options),
|
||||
});
|
||||
try {
|
||||
return use(editor);
|
||||
} finally {
|
||||
editor.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export interface RichText {
|
||||
/** Plain text, one line per block; `""` while the field is missing or a plain value. */
|
||||
text(): string;
|
||||
html(): string;
|
||||
markdown(): string;
|
||||
/** Replaces the field's content in one write no undo reverts; a no-op while the field is missing or the viewer cannot edit. */
|
||||
setMarkdown(markdown: string): void;
|
||||
}
|
||||
|
||||
/** Reads and replaces a `sharedRichText()` field outside render; pass the editor's `extensions` so both use one schema. Throws, naming the kit to use, when another kit's field is there. */
|
||||
export function richText(
|
||||
doc: string,
|
||||
field: string,
|
||||
options: Pick<EditorOptions, "extensions"> = {},
|
||||
): RichText {
|
||||
const bound = bindOpaque(
|
||||
field,
|
||||
opaqueField(doc, field, "xml", "prosemirror"),
|
||||
);
|
||||
const json = (): JSONContent => {
|
||||
const { value } = bound;
|
||||
return value
|
||||
? (yXmlFragmentToProsemirrorJSON(value) as JSONContent)
|
||||
: { type: "doc", content: [] };
|
||||
};
|
||||
return {
|
||||
text: () => (bound.value ? plainTextOf(bound.value) : ""),
|
||||
html: () =>
|
||||
withEditor(options, (editor) =>
|
||||
getHTMLFromFragment(
|
||||
PMNode.fromJSON(editor.schema, json()).content,
|
||||
editor.schema,
|
||||
),
|
||||
),
|
||||
markdown: () =>
|
||||
withEditor(options, (editor) => editor.markdown!.serialize(json())),
|
||||
setMarkdown: (markdown) =>
|
||||
withEditor(options, (editor) => {
|
||||
const { value } = bound;
|
||||
if (!value || !bound.canEdit) {
|
||||
return;
|
||||
}
|
||||
const content = editor.markdown!.parse(markdown);
|
||||
bound.write(() =>
|
||||
prosemirrorJSONToYXmlFragment(editor.schema, content, value),
|
||||
);
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { Extension, type Editor as TiptapEditor } from "@tiptap/core";
|
||||
import { isChangeOrigin } from "@tiptap/extension-collaboration";
|
||||
import { PluginKey, type Transaction } from "@tiptap/pm/state";
|
||||
import { ReplaceStep } from "@tiptap/pm/transform";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import {
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListChecks,
|
||||
ListOrdered,
|
||||
Minus,
|
||||
Quote,
|
||||
Shapes,
|
||||
Table,
|
||||
Type,
|
||||
} from "lucide-react";
|
||||
import type { SlashItem } from "./options";
|
||||
import { menuRenderer, type MenuItem, popupOptions } from "./suggestions";
|
||||
|
||||
export interface SlashOptions {
|
||||
/** The author's entries, listed under "More". */
|
||||
items: SlashItem[];
|
||||
/** Whether the built-in blocks are listed. */
|
||||
blocks: boolean;
|
||||
}
|
||||
|
||||
interface SlashEntry extends SlashItem, MenuItem {}
|
||||
|
||||
interface SlashState {
|
||||
active: boolean;
|
||||
range: { from: number; to: number };
|
||||
}
|
||||
|
||||
const setBlock =
|
||||
(type: string, attrs?: Record<string, unknown>) => (e: TiptapEditor) =>
|
||||
e.chain().focus().clearNodes().setNode(type, attrs).run();
|
||||
|
||||
const wrapList = (list: string, item: string) => (e: TiptapEditor) => {
|
||||
if (!e.isActive(list)) {
|
||||
e.chain().focus().clearNodes().toggleList(list, item).run();
|
||||
}
|
||||
};
|
||||
|
||||
const BLOCKS: readonly SlashEntry[] = [
|
||||
{
|
||||
id: "text",
|
||||
label: "Text",
|
||||
description: "Plain paragraph text.",
|
||||
keywords: "paragraph plain p",
|
||||
group: "Basic blocks",
|
||||
icon: Type,
|
||||
run: setBlock("paragraph"),
|
||||
},
|
||||
{
|
||||
id: "h1",
|
||||
label: "Heading 1",
|
||||
description: "Big section heading.",
|
||||
keywords: "h1 title",
|
||||
hint: "#",
|
||||
group: "Basic blocks",
|
||||
icon: Heading1,
|
||||
run: setBlock("heading", { level: 1 }),
|
||||
},
|
||||
{
|
||||
id: "h2",
|
||||
label: "Heading 2",
|
||||
description: "Medium section heading.",
|
||||
keywords: "h2 subtitle",
|
||||
hint: "##",
|
||||
group: "Basic blocks",
|
||||
icon: Heading2,
|
||||
run: setBlock("heading", { level: 2 }),
|
||||
},
|
||||
{
|
||||
id: "h3",
|
||||
label: "Heading 3",
|
||||
description: "Small section heading.",
|
||||
keywords: "h3",
|
||||
hint: "###",
|
||||
group: "Basic blocks",
|
||||
icon: Heading3,
|
||||
run: setBlock("heading", { level: 3 }),
|
||||
},
|
||||
{
|
||||
id: "quote",
|
||||
label: "Quote",
|
||||
description: "A highlighted quotation.",
|
||||
keywords: "blockquote citation",
|
||||
hint: ">",
|
||||
group: "Basic blocks",
|
||||
icon: Quote,
|
||||
run: (e) => e.chain().focus().clearNodes().wrapIn("blockquote").run(),
|
||||
},
|
||||
{
|
||||
id: "divider",
|
||||
label: "Divider",
|
||||
description: "A line between blocks.",
|
||||
keywords: "hr rule line separator",
|
||||
hint: "---",
|
||||
group: "Basic blocks",
|
||||
icon: Minus,
|
||||
run: (e) => e.chain().focus().clearNodes().setHorizontalRule().run(),
|
||||
},
|
||||
{
|
||||
id: "bullets",
|
||||
label: "Bullet list",
|
||||
description: "A simple bulleted list.",
|
||||
keywords: "bulleted unordered ul",
|
||||
hint: "-",
|
||||
group: "Lists",
|
||||
icon: List,
|
||||
run: wrapList("bulletList", "listItem"),
|
||||
},
|
||||
{
|
||||
id: "numbers",
|
||||
label: "Numbered list",
|
||||
description: "A list with numbering.",
|
||||
keywords: "ordered ol",
|
||||
hint: "1.",
|
||||
group: "Lists",
|
||||
icon: ListOrdered,
|
||||
run: wrapList("orderedList", "listItem"),
|
||||
},
|
||||
{
|
||||
id: "tasks",
|
||||
label: "Task list",
|
||||
description: "Tasks with checkboxes.",
|
||||
keywords: "todo to-do checkbox checklist",
|
||||
hint: "[]",
|
||||
group: "Lists",
|
||||
icon: ListChecks,
|
||||
run: wrapList("taskList", "taskItem"),
|
||||
},
|
||||
{
|
||||
id: "code",
|
||||
label: "Code block",
|
||||
description: "Code in monospace.",
|
||||
keywords: "pre snippet",
|
||||
hint: "```",
|
||||
group: "Advanced",
|
||||
icon: Code,
|
||||
run: setBlock("codeBlock"),
|
||||
},
|
||||
{
|
||||
id: "table",
|
||||
label: "Table",
|
||||
description: "Rows and columns.",
|
||||
keywords: "grid rows columns",
|
||||
group: "Advanced",
|
||||
icon: Table,
|
||||
run: (e) =>
|
||||
e
|
||||
.chain()
|
||||
.focus()
|
||||
.clearNodes()
|
||||
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||
.run(),
|
||||
},
|
||||
];
|
||||
|
||||
/** The `/` menu: opens on a typed `/`, filters as the query grows, replaces `/query` with the pick. */
|
||||
export const Slash = Extension.create<SlashOptions>({
|
||||
name: "slash",
|
||||
|
||||
addOptions() {
|
||||
return { items: [], blocks: true };
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const key = new PluginKey<SlashState>("slash");
|
||||
const entries: SlashEntry[] = [
|
||||
...(this.options.blocks ? BLOCKS : []),
|
||||
...this.options.items.map((item) => ({
|
||||
group: "More",
|
||||
icon: Shapes,
|
||||
...item,
|
||||
})),
|
||||
];
|
||||
return [
|
||||
Suggestion<SlashEntry, SlashEntry>({
|
||||
...popupOptions,
|
||||
editor: this.editor,
|
||||
pluginKey: key,
|
||||
char: "/",
|
||||
allowSpaces: true,
|
||||
allowedPrefixes: null,
|
||||
allow: ({ state, range }) => {
|
||||
const $caret = state.doc.resolve(range.to);
|
||||
return (
|
||||
!$caret.parent.type.spec.code &&
|
||||
!$caret.marks().some((mark) => mark.type.spec.code)
|
||||
);
|
||||
},
|
||||
shouldShow: ({ editor, range, query, transaction }) => {
|
||||
if (/^\s/.test(query) || query.includes("/")) {
|
||||
return false;
|
||||
}
|
||||
const before = key.getState(editor.state);
|
||||
return before?.active && sameSlash(before, range, transaction)
|
||||
? staysOpen(entries, query)
|
||||
: typedSlash(transaction);
|
||||
},
|
||||
items: ({ query }) => rank(entries, query),
|
||||
command: ({ editor, range, props }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
props.run(editor);
|
||||
},
|
||||
render: menuRenderer<SlashEntry>(key, "slash"),
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
/** Whether the match is still the `/` the menu opened on. */
|
||||
function sameSlash(
|
||||
before: SlashState,
|
||||
range: { from: number },
|
||||
transaction: Transaction,
|
||||
): boolean {
|
||||
return (
|
||||
isChangeOrigin(transaction) ||
|
||||
transaction.mapping.map(before.range.from) === range.from
|
||||
);
|
||||
}
|
||||
|
||||
function staysOpen(entries: SlashEntry[], query: string): boolean {
|
||||
return !(/\s/.test(query) && rank(entries, query).length === 0);
|
||||
}
|
||||
|
||||
/** Whether this transaction is the person typing a `/`, not a paste, an undo or a remote edit. */
|
||||
function typedSlash(transaction: Transaction): boolean {
|
||||
const [step] = transaction.steps;
|
||||
return (
|
||||
transaction.steps.length === 1 &&
|
||||
step instanceof ReplaceStep &&
|
||||
!transaction.getMeta("uiEvent") &&
|
||||
!isChangeOrigin(transaction) &&
|
||||
step.slice.content.textBetween(0, step.slice.content.size).endsWith("/")
|
||||
);
|
||||
}
|
||||
|
||||
/** The entries matching `query`, groups ordered by their best score and entries by score within a group; ties keep listed order. */
|
||||
function rank(entries: SlashEntry[], query: string): SlashEntry[] {
|
||||
const wanted = query.trim().toLowerCase();
|
||||
if (!wanted) {
|
||||
return entries;
|
||||
}
|
||||
const hits = entries
|
||||
.map((entry, index) => ({ entry, index, score: score(entry, wanted) }))
|
||||
.filter((hit) => hit.score < Infinity);
|
||||
const groups = new Map<string | undefined, { best: number; first: number }>();
|
||||
for (const { entry, index, score: hitScore } of hits) {
|
||||
const group = groups.get(entry.group);
|
||||
if (!group) {
|
||||
groups.set(entry.group, { best: hitScore, first: index });
|
||||
} else {
|
||||
group.best = Math.min(group.best, hitScore);
|
||||
}
|
||||
}
|
||||
return hits
|
||||
.sort((a, b) => {
|
||||
if (a.entry.group !== b.entry.group) {
|
||||
const groupA = groups.get(a.entry.group)!;
|
||||
const groupB = groups.get(b.entry.group)!;
|
||||
return groupA.best - groupB.best || groupA.first - groupB.first;
|
||||
}
|
||||
return a.score - b.score || a.index - b.index;
|
||||
})
|
||||
.map((hit) => hit.entry);
|
||||
}
|
||||
|
||||
function score(entry: SlashEntry, wanted: string): number {
|
||||
const label = entry.label.toLowerCase();
|
||||
const words = [
|
||||
...label.split(/\s+/),
|
||||
...(entry.keywords ?? "").toLowerCase().split(/\s+/),
|
||||
].filter(Boolean);
|
||||
if (label.startsWith(wanted)) {
|
||||
return 0;
|
||||
}
|
||||
if (words.some((word) => word.startsWith(wanted))) {
|
||||
return 1;
|
||||
}
|
||||
if (wanted.length < 2) {
|
||||
return Infinity;
|
||||
}
|
||||
if (label.includes(wanted)) {
|
||||
return 2;
|
||||
}
|
||||
return inOrder(wanted.replace(/\s/g, ""), label.replace(/\s/g, ""))
|
||||
? 3
|
||||
: Infinity;
|
||||
}
|
||||
|
||||
function inOrder(letters: string, text: string): boolean {
|
||||
let at = 0;
|
||||
for (const letter of letters) {
|
||||
at = text.indexOf(letter, at) + 1;
|
||||
if (at === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { LABEL } from "@kits/sync/internal";
|
||||
|
||||
const muted = "var(--muted, hsl(240 4.8% 95.9%))";
|
||||
const mutedForeground = "var(--muted-foreground, hsl(240 3.8% 46.1%))";
|
||||
const border = "var(--border, hsl(240 5.9% 90%))";
|
||||
const ring = "var(--ring, hsl(240 5% 64.9%))";
|
||||
const primary = "var(--primary, hsl(240 5.9% 10%))";
|
||||
const popover = "var(--popover, hsl(0 0% 100%))";
|
||||
const popoverForeground = "var(--popover-foreground, hsl(240 10% 3.9%))";
|
||||
const accent = "var(--accent, hsl(240 4.8% 95.9%))";
|
||||
const accentForeground = "var(--accent-foreground, hsl(240 5.9% 10%))";
|
||||
|
||||
export const EDITOR_CSS = `
|
||||
.ck-editor { position: relative; }
|
||||
.ck-editor[data-toolbar="fixed"] { border: 1px solid ${border}; border-radius: 0.5rem; }
|
||||
.ck-editor[data-toolbar="fixed"]:focus-within { box-shadow: 0 0 0 2px ${ring}; }
|
||||
.ck-editor[data-toolbar="fixed"] .ck-content { padding: 0.5rem 0.75rem; }
|
||||
.ProseMirror { outline: none; min-height: 1.5em; white-space: pre-wrap; word-wrap: break-word; }
|
||||
.ProseMirror > * + * { margin-top: 0.5em; }
|
||||
.ProseMirror p.is-editor-empty:first-child::before { content: attr(data-placeholder); color: ${mutedForeground}; float: left; height: 0; pointer-events: none; }
|
||||
.ProseMirror a { color: ${primary}; text-decoration: underline; }
|
||||
.ProseMirror code { background: ${muted}; border-radius: 0.25rem; padding: 0.1em 0.3em; font-size: 0.9em; }
|
||||
.ProseMirror pre { background: ${muted}; border-radius: 0.375rem; padding: 0.75rem 1rem; overflow-x: auto; }
|
||||
.ProseMirror pre code { background: none; padding: 0; font-size: inherit; }
|
||||
.ProseMirror blockquote { border-left: 3px solid ${border}; padding-left: 1rem; color: ${mutedForeground}; }
|
||||
.ProseMirror hr { border: 0; border-top: 1px solid ${border}; margin: 1rem 0; }
|
||||
.ProseMirror ul, .ProseMirror ol { padding-left: 1.5rem; }
|
||||
.ProseMirror ul { list-style: disc; }
|
||||
.ProseMirror ol { list-style: decimal; }
|
||||
.ProseMirror ul[data-type="taskList"] { list-style: none; padding-left: 0; }
|
||||
.ProseMirror ul[data-type="taskList"] li { display: flex; gap: 0.5rem; align-items: flex-start; }
|
||||
.ProseMirror ul[data-type="taskList"] li > label { display: flex; flex: 0 0 auto; align-items: center; height: 1.5em; height: 1lh; }
|
||||
.ProseMirror ul[data-type="taskList"] li > label input { width: 1em; height: 1em; margin: 0; accent-color: ${primary}; }
|
||||
.ProseMirror ul[data-type="taskList"] li > div { flex: 1 1 auto; min-width: 0; }
|
||||
.ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div { color: ${mutedForeground}; text-decoration: line-through; }
|
||||
.ProseMirror table { border-collapse: collapse; width: 100%; table-layout: fixed; }
|
||||
.ProseMirror td, .ProseMirror th { border: 1px solid ${border}; padding: 0.25rem 0.5rem; vertical-align: top; }
|
||||
.ProseMirror th { background: ${muted}; font-weight: 600; text-align: left; }
|
||||
.ProseMirror img { max-width: 100%; height: auto; }
|
||||
.ProseMirror .mention { color: ${primary}; background: color-mix(in srgb, ${primary} 12%, transparent); border-radius: 0.25rem; padding: 0 0.2em; }
|
||||
.ProseMirror-yjs-cursor { position: relative; margin-left: -1px; margin-right: -1px; border-left: 1px solid; border-right: 1px solid; word-break: normal; pointer-events: none; }
|
||||
.ProseMirror-yjs-cursor > div { position: absolute; bottom: 100%; left: -1px; font-size: ${LABEL.fontSize}; line-height: 1; padding: ${LABEL.padding}; border-radius: ${LABEL.radius} ${LABEL.radius} ${LABEL.radius} ${LABEL.tail}; white-space: nowrap; user-select: none; font-style: normal; font-weight: 500; }
|
||||
.ck-toolbar { display: flex; flex-wrap: wrap; gap: 0.125rem; align-items: center; }
|
||||
.ck-toolbar[data-placement="fixed"] { padding: 0.25rem 0.5rem; border-bottom: 1px solid ${border}; }
|
||||
.ck-toolbar[data-placement="bubble"] { padding: 0.25rem; border: 1px solid ${border}; border-radius: 0.5rem; background: ${popover}; color: ${popoverForeground}; box-shadow: 0 4px 12px rgb(0 0 0 / 0.12); }
|
||||
.ck-toolbar button { display: inline-flex; align-items: center; justify-content: center; width: 1.75rem; height: 1.75rem; border: 0; border-radius: 0.375rem; background: none; color: inherit; cursor: pointer; }
|
||||
.ck-toolbar button:hover { background: ${accent}; color: ${accentForeground}; }
|
||||
.ck-toolbar button[aria-pressed="true"] { background: ${accent}; color: ${primary}; }
|
||||
.ck-toolbar button:focus-visible { outline: 2px solid ${ring}; outline-offset: 1px; }
|
||||
.ck-toolbar button svg { width: 1rem; height: 1rem; }
|
||||
.ck-toolbar input { height: 1.75rem; min-width: 14rem; padding: 0 0.5rem; border: 1px solid ${border}; border-radius: 0.375rem; background: transparent; color: inherit; font: inherit; }
|
||||
.ck-toolbar input:focus-visible { outline: 2px solid ${ring}; outline-offset: 1px; }
|
||||
.ck-popup { position: fixed; top: 0; left: 0; z-index: 60; width: max-content; line-height: 1.3; transform-origin: 0 0; }
|
||||
.ck-popup[data-placement^="top"] { transform-origin: 0 100%; }
|
||||
.ck-popup:not([data-state]) { visibility: hidden; }
|
||||
.ck-popup[data-state="open"] { animation: ck-menu-grow 150ms ease-out, ck-menu-fade 150ms ease-out; }
|
||||
.ck-popup[data-state="closed"] { animation: ck-menu-shrink 120ms ease-in forwards; pointer-events: none; }
|
||||
@keyframes ck-menu-grow { from { transform: scale(0.96); } to { transform: scale(1); } }
|
||||
@keyframes ck-menu-fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes ck-menu-shrink { to { opacity: 0; transform: scale(0.97); } }
|
||||
.ck-menu { box-sizing: border-box; min-width: 12rem; max-width: 22rem; max-height: min(22rem, var(--ck-menu-max, 22rem)); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; padding: 0.375rem; border: 1px solid color-mix(in srgb, ${popoverForeground} 10%, transparent); border-radius: 0.75rem; background: ${popover}; color: ${popoverForeground}; box-shadow: 0 1px 2px rgb(0 0 0 / 0.06), 0 4px 12px rgb(0 0 0 / 0.08), 0 16px 40px -8px rgb(0 0 0 / 0.2); }
|
||||
.ck-popup[data-kind="slash"] .ck-menu { width: 19rem; }
|
||||
.ck-menu [role="group"] + [role="group"] { margin-top: 0.25rem; padding-top: 0.25rem; border-top: 1px solid color-mix(in srgb, ${popoverForeground} 8%, transparent); }
|
||||
.ck-menu-group { padding: 0.375rem 0.5rem 0.25rem; font-size: 0.78em; font-weight: 500; letter-spacing: 0.01em; color: ${mutedForeground}; }
|
||||
.ck-menu button[role="option"] { display: flex; align-items: center; gap: 0.625rem; width: 100%; padding: 0.375rem 0.5rem; border: 0; border-radius: 0.5rem; background: none; color: inherit; font: inherit; line-height: inherit; text-align: left; cursor: pointer; transition: background-color 80ms ease-out; -webkit-tap-highlight-color: transparent; }
|
||||
.ck-menu button[aria-selected="true"] { background: ${accent}; color: ${accentForeground}; }
|
||||
.ck-menu-icon { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 1.875rem; height: 1.875rem; border: 1px solid color-mix(in srgb, ${popoverForeground} 10%, transparent); border-radius: 0.5rem; background: ${popover}; color: ${popoverForeground}; box-shadow: 0 1px 2px rgb(0 0 0 / 0.05); }
|
||||
.ck-menu-icon svg { width: 1.0625rem; height: 1.0625rem; }
|
||||
.ck-menu-text { display: flex; flex: 1 1 auto; flex-direction: column; min-width: 0; gap: 0.0625rem; }
|
||||
.ck-menu-title { overflow: hidden; font-weight: 500; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.ck-menu-description { overflow: hidden; font-size: 0.86em; color: ${mutedForeground}; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.ck-menu-hint { flex: none; min-width: 1.25rem; height: 1.25rem; padding: 0 0.3rem; border-radius: 0.3rem; background: ${muted}; color: ${mutedForeground}; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75em; line-height: 1.25rem; text-align: center; }
|
||||
.ck-menu button[aria-selected="true"] .ck-menu-hint { background: color-mix(in srgb, currentColor 10%, transparent); }
|
||||
.ck-menu-empty { padding: 0.875rem 0.5rem; color: ${mutedForeground}; font-size: 0.93em; text-align: center; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ck-popup[data-state] { animation: none; }
|
||||
.ck-menu button[role="option"] { transition: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
let adopted = false;
|
||||
|
||||
export function adoptEditorStyles(): void {
|
||||
if (adopted || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
adopted = true;
|
||||
if (
|
||||
"adoptedStyleSheets" in document &&
|
||||
"replaceSync" in CSSStyleSheet.prototype
|
||||
) {
|
||||
const sheet = new CSSStyleSheet();
|
||||
sheet.replaceSync(EDITOR_CSS);
|
||||
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
|
||||
return;
|
||||
}
|
||||
const style = document.createElement("style");
|
||||
style.textContent = EDITOR_CSS;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { shift, size } from "@floating-ui/dom";
|
||||
import type { PluginKey } from "@tiptap/pm/state";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import type {
|
||||
SuggestionKeyDownProps,
|
||||
SuggestionOptions,
|
||||
SuggestionProps,
|
||||
} from "@tiptap/suggestion";
|
||||
import { exitSuggestion } from "@tiptap/suggestion";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactElement } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export interface MenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
/** The Markdown shortcut, drawn as a key at the right edge. */
|
||||
hint?: string;
|
||||
/** Consecutive items with the same group share a heading. */
|
||||
group?: string;
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
interface MenuHandle {
|
||||
onKeyDown(props: SuggestionKeyDownProps): boolean;
|
||||
}
|
||||
|
||||
export const popupOptions: Pick<SuggestionOptions, "offset" | "floatingUi"> = {
|
||||
offset: { mainAxis: 6 },
|
||||
floatingUi: {
|
||||
strategy: "fixed",
|
||||
middleware: [
|
||||
shift({ padding: 8 }),
|
||||
size({
|
||||
padding: 8,
|
||||
apply: ({ availableHeight, elements }) =>
|
||||
elements.floating.style.setProperty(
|
||||
"--ck-menu-max",
|
||||
`${Math.max(availableHeight, 96)}px`,
|
||||
),
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** The renderer the `/` and `@` menus share; `kind` names the popup for styling. */
|
||||
export function menuRenderer<Item extends MenuItem>(
|
||||
pluginKey: PluginKey,
|
||||
kind: "slash" | "mention",
|
||||
): NonNullable<SuggestionOptions<Item>["render"]> {
|
||||
return () => {
|
||||
let popup: Popup<Item> | undefined;
|
||||
return {
|
||||
onStart: (props) => {
|
||||
popup?.close();
|
||||
popup = openPopup(props, pluginKey, kind);
|
||||
},
|
||||
onUpdate: (props) => popup?.update(props),
|
||||
onKeyDown: (props) => popup?.keyDown(props) ?? false,
|
||||
onExit: () => {
|
||||
popup?.close();
|
||||
popup = undefined;
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface Popup<Item extends MenuItem> {
|
||||
update(props: SuggestionProps<Item>): void;
|
||||
keyDown(props: SuggestionKeyDownProps): boolean;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/** One open menu, mounted on `document.body` and torn down after its exit animation. */
|
||||
function openPopup<Item extends MenuItem>(
|
||||
props: SuggestionProps<Item>,
|
||||
pluginKey: PluginKey,
|
||||
kind: string,
|
||||
): Popup<Item> {
|
||||
const { editor } = props;
|
||||
const surface = editor.view.dom;
|
||||
const renderer = new ReactRenderer<MenuHandle, MenuProps>(Menu, {
|
||||
editor,
|
||||
props: menuProps(props),
|
||||
className: "ck-popup",
|
||||
});
|
||||
const { element } = renderer;
|
||||
element.dataset.kind = kind;
|
||||
adoptTypography(element, surface);
|
||||
document.body.appendChild(element);
|
||||
const unmount = props.mount(element, {
|
||||
autoUpdate: { animationFrame: true },
|
||||
onPosition: ({ x, y, placement }) => {
|
||||
element.style.left = `${x}px`;
|
||||
element.style.top = `${y}px`;
|
||||
element.dataset.placement = placement;
|
||||
element.dataset.state ||= "open";
|
||||
},
|
||||
});
|
||||
const dismiss = () => exitSuggestion(editor.view, pluginKey);
|
||||
surface.addEventListener("blur", dismiss);
|
||||
|
||||
let closed = false;
|
||||
return {
|
||||
update(next) {
|
||||
renderer.updateProps(menuProps(next));
|
||||
},
|
||||
keyDown(keyProps) {
|
||||
return renderer.ref?.onKeyDown(keyProps) ?? false;
|
||||
},
|
||||
close() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
unmount();
|
||||
surface.removeEventListener("blur", dismiss);
|
||||
const remove = () => {
|
||||
if (element.isConnected) {
|
||||
renderer.destroy();
|
||||
}
|
||||
};
|
||||
if (
|
||||
matchMedia("(prefers-reduced-motion: reduce)").matches ||
|
||||
!element.dataset.state
|
||||
) {
|
||||
remove();
|
||||
return;
|
||||
}
|
||||
element.dataset.state = "closed";
|
||||
element.addEventListener("animationend", remove);
|
||||
setTimeout(remove, 200);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function menuProps<Item extends MenuItem>(
|
||||
props: SuggestionProps<Item>,
|
||||
): MenuProps {
|
||||
return {
|
||||
items: props.items,
|
||||
command: props.command as (item: MenuItem) => void,
|
||||
loading: props.loading,
|
||||
};
|
||||
}
|
||||
|
||||
function adoptTypography(element: HTMLElement, surface: HTMLElement): void {
|
||||
const { fontFamily, fontSize } = getComputedStyle(surface);
|
||||
element.style.fontFamily = fontFamily;
|
||||
element.style.fontSize = `${Math.min(Math.max(parseFloat(fontSize) || 14, 13), 15)}px`;
|
||||
}
|
||||
|
||||
interface MenuProps {
|
||||
items: MenuItem[];
|
||||
command: (item: MenuItem) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
interface Run {
|
||||
group: string | undefined;
|
||||
entries: Array<[MenuItem, number]>;
|
||||
}
|
||||
|
||||
const Menu = forwardRef<MenuHandle, MenuProps>(function Menu(
|
||||
{ items, command, loading },
|
||||
ref,
|
||||
): ReactElement {
|
||||
const [state, setState] = useState({ items, selected: 0 });
|
||||
if (state.items !== items) {
|
||||
setState({ items, selected: 0 });
|
||||
}
|
||||
const selected = Math.min(state.selected, Math.max(items.length - 1, 0));
|
||||
const select = (index: number) => setState({ items, selected: index });
|
||||
const list = useRef<HTMLDivElement>(null);
|
||||
const scrollTo = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
if (scrollTo.current) {
|
||||
scrollTo.current = false;
|
||||
list.current
|
||||
?.querySelector(`[data-index="${selected}"]`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}, [selected]);
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
onKeyDown: ({ event }) => {
|
||||
if (items.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
||||
const step = event.key === "ArrowUp" ? -1 : 1;
|
||||
scrollTo.current = true;
|
||||
select((selected + step + items.length) % items.length);
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(event.key === "Enter" || event.key === "Tab") &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey
|
||||
) {
|
||||
command(items[selected]!);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
[items, selected, command],
|
||||
);
|
||||
const runs: Run[] = [];
|
||||
items.forEach((item, index) => {
|
||||
const last = runs[runs.length - 1];
|
||||
if (last && last.group === item.group) {
|
||||
last.entries.push([item, index]);
|
||||
} else {
|
||||
runs.push({ group: item.group, entries: [[item, index]] });
|
||||
}
|
||||
});
|
||||
return (
|
||||
<div
|
||||
className="ck-menu"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
ref={list}
|
||||
role="listbox"
|
||||
>
|
||||
{items.length === 0 && (
|
||||
<div className="ck-menu-empty">
|
||||
{loading ? "Searching…" : "No results"}
|
||||
</div>
|
||||
)}
|
||||
{runs.map(({ group, entries }) => (
|
||||
<div aria-label={group} key={`${group}:${entries[0]![1]}`} role="group">
|
||||
{group && <div className="ck-menu-group">{group}</div>}
|
||||
{entries.map(([item, index]) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
aria-selected={index === selected}
|
||||
data-index={index}
|
||||
key={item.id}
|
||||
onClick={() => command(item)}
|
||||
onMouseMove={() => index !== selected && select(index)}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
{Icon && (
|
||||
<span className="ck-menu-icon">
|
||||
<Icon aria-hidden="true" strokeWidth={1.75} />
|
||||
</span>
|
||||
)}
|
||||
<span className="ck-menu-text">
|
||||
<span className="ck-menu-title">{item.label}</span>
|
||||
{item.description && (
|
||||
<span className="ck-menu-description">
|
||||
{item.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{item.hint && <kbd className="ck-menu-hint">{item.hint}</kbd>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { shared } from "@kits/sync";
|
||||
import { useOpaqueField } from "@kits/sync/internal";
|
||||
import type { Editor as TiptapEditor } from "@tiptap/core";
|
||||
import { useEditor as useTiptapEditor } from "@tiptap/react";
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { bindOpaque } from "../bindOpaque";
|
||||
import { warnOnce } from "../warn";
|
||||
import { editorExtensions, matching } from "./extensions";
|
||||
import type { EditorOptions, SlashItem } from "./options";
|
||||
import { adoptEditorStyles } from "./styles";
|
||||
|
||||
export interface LocalEditorOptions extends EditorOptions {
|
||||
/** Markdown; `onChange` gets Markdown after every edit. */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/** The editor instance over `value`/`onChange`, to render with `EditorContent`. */
|
||||
export function useEditor(options: LocalEditorOptions): TiptapEditor {
|
||||
const latest = useLatest(options);
|
||||
const emitted = useRef(options.value);
|
||||
const editor = useTiptapEditor(
|
||||
{
|
||||
...instanceOptions(latest),
|
||||
extensions: editorExtensions(stableCallbacks(options, latest)),
|
||||
content: options.value,
|
||||
contentType: "markdown",
|
||||
editable: !options.readOnly,
|
||||
onUpdate: ({ editor }) => {
|
||||
const value = editor.getMarkdown();
|
||||
emitted.current = value;
|
||||
latest.current.onChange(value);
|
||||
},
|
||||
},
|
||||
signatureOf(options),
|
||||
);
|
||||
useEffect(() => {
|
||||
if (options.value !== emitted.current) {
|
||||
emitted.current = options.value;
|
||||
editor.commands.setContent(options.value, {
|
||||
contentType: "markdown",
|
||||
emitUpdate: false,
|
||||
});
|
||||
}
|
||||
}, [editor, options.value]);
|
||||
useLayoutEffect(adoptEditorStyles, []);
|
||||
useEditable(editor, !options.readOnly);
|
||||
return editor;
|
||||
}
|
||||
|
||||
/** The editor instance over a `sharedRichText()` field, to render with `EditorContent`; suspends until shared state has loaded. */
|
||||
export function useSharedEditor(
|
||||
doc: string,
|
||||
field: string,
|
||||
options: EditorOptions = {},
|
||||
): TiptapEditor {
|
||||
const bound = bindOpaque(
|
||||
field,
|
||||
useOpaqueField(doc, field, "xml", "prosemirror"),
|
||||
);
|
||||
const latest = useLatest(options);
|
||||
const editable = !options.readOnly && bound.canEdit;
|
||||
const editor = useTiptapEditor(
|
||||
{
|
||||
...instanceOptions(latest),
|
||||
extensions: editorExtensions(stableCallbacks(options, latest), bound),
|
||||
editable,
|
||||
},
|
||||
[bound.value, bound.awareness, ...signatureOf(options)],
|
||||
);
|
||||
useLayoutEffect(adoptEditorStyles, []);
|
||||
useEditable(editor, editable);
|
||||
useEffect(() => {
|
||||
if (bound.value === undefined && shared.doc(doc).exists) {
|
||||
warnOnce(
|
||||
`editor:${doc}:${field}`,
|
||||
`"${field}" on "${doc}" holds no rich text; write sharedRichText() to it, then change it only through the Editor or richText().setMarkdown().`,
|
||||
);
|
||||
}
|
||||
}, [bound.value, doc, field]);
|
||||
return editor;
|
||||
}
|
||||
|
||||
function useLatest<T>(value: T) {
|
||||
const latest = useRef(value);
|
||||
latest.current = value;
|
||||
return latest;
|
||||
}
|
||||
|
||||
const INLINE_RULES = ["bold", "italic", "strike", "code", "underline", "link"];
|
||||
|
||||
function instanceOptions(latest: { current: EditorOptions }) {
|
||||
return {
|
||||
immediatelyRender: true as const,
|
||||
autofocus: latest.current.autoFocus ? ("end" as const) : false,
|
||||
enableInputRules: latest.current.single ? INLINE_RULES : true,
|
||||
onCreate: ({ editor }: { editor: TiptapEditor }) =>
|
||||
latest.current.onReady?.(editor),
|
||||
};
|
||||
}
|
||||
|
||||
function stableCallbacks<T extends EditorOptions>(
|
||||
options: T,
|
||||
latest: { current: EditorOptions },
|
||||
): T {
|
||||
const { mentions, slash } = options;
|
||||
return {
|
||||
...options,
|
||||
onSubmit:
|
||||
options.onSubmit &&
|
||||
((editor: TiptapEditor) => latest.current.onSubmit?.(editor)),
|
||||
mentions:
|
||||
mentions === undefined || mentions === false
|
||||
? mentions
|
||||
: (query: string) => {
|
||||
const current = latest.current.mentions;
|
||||
return typeof current === "function"
|
||||
? current(query)
|
||||
: matching(Array.isArray(current) ? current : [], query);
|
||||
},
|
||||
slash: Array.isArray(slash)
|
||||
? slash.map((item) => ({
|
||||
...item,
|
||||
run: (editor: TiptapEditor) =>
|
||||
latestSlashItem(latest.current.slash, item.id)?.run(editor),
|
||||
}))
|
||||
: slash,
|
||||
};
|
||||
}
|
||||
|
||||
function latestSlashItem(
|
||||
slash: EditorOptions["slash"],
|
||||
id: string,
|
||||
): SlashItem | undefined {
|
||||
return Array.isArray(slash)
|
||||
? slash.find((item) => item.id === id)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** The deps that rebuild the editor. */
|
||||
function signatureOf(options: EditorOptions): unknown[] {
|
||||
return [
|
||||
Boolean(options.single),
|
||||
options.placeholder,
|
||||
JSON.stringify(options.slash ?? null),
|
||||
options.mentions === false,
|
||||
Boolean(options.onSubmit),
|
||||
(options.extensions ?? []).map((extension) => extension.name).join(","),
|
||||
];
|
||||
}
|
||||
|
||||
function useEditable(editor: TiptapEditor, editable: boolean) {
|
||||
useEffect(() => {
|
||||
if (editor.isEditable !== editable) {
|
||||
editor.setEditable(editable);
|
||||
}
|
||||
}, [editor, editable]);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type { LocalBinding, SharedBinding } from "./binding";
|
||||
@@ -0,0 +1,8 @@
|
||||
const warned = new Set<string>();
|
||||
|
||||
export function warnOnce(key: string, message: string): void {
|
||||
if (!warned.has(key)) {
|
||||
warned.add(key);
|
||||
console.warn(`[components] ${message}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user