feat: bilingual support (FA/EN), IRANSans typography, RTL layout, and interactive product detail pages

This commit is contained in:
s.esmaeili
2026-10-04 07:11:52 +03:30
commit a001fa5d5f
302 changed files with 35491 additions and 0 deletions
+31
View File
@@ -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`.
+108
View File
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
export {
} from "./index.ts";
+20
View File
@@ -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;
}
+15
View File
@@ -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;
}
+20
View File
@@ -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";
+72
View File
@@ -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>
);
}
+75
View File
@@ -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;
}
+229
View File
@@ -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();
}
}
+175
View File
@@ -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);
}
+30
View File
@@ -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";
+89
View File
@@ -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;
}
+39
View File
@@ -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;
}
+48
View File
@@ -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;
}
+138
View File
@@ -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),
);
}),
};
}
+307
View File
@@ -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;
}
+100
View File
@@ -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);
}
+274
View File
@@ -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>
);
});
+158
View File
@@ -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]);
}
+1
View File
@@ -0,0 +1 @@
export type { LocalBinding, SharedBinding } from "./binding";
+8
View File
@@ -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}`);
}
}