diff --git a/apps/www/src/content/docs/components/editor/demo.ts b/apps/www/src/content/docs/components/editor/demo.ts new file mode 100644 index 000000000..a2101eb38 --- /dev/null +++ b/apps/www/src/content/docs/components/editor/demo.ts @@ -0,0 +1,366 @@ +'use client'; + +import type { ComponentPropsType } from '@/components/demo/types'; +import { getPropsString } from '@/lib/utils'; + +const frame = + "{{ border: '0.5px solid var(--rs-color-border-base-primary)', borderRadius: 'var(--rs-radius-3)', padding: 'var(--rs-space-4)', minHeight: 120 }}"; + +const sampleDoc = `{ + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 2 }, + content: [{ type: 'text', text: 'Release notes' }] + }, + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Select this text to format it, type ' }, + { type: 'text', marks: [{ type: 'code' }], text: '/' }, + { type: 'text', text: ' for commands, or ' }, + { type: 'text', marks: [{ type: 'code' }], text: '@' }, + { type: 'text', text: ' to mention someone.' } + ] + } + ] +}`; + +const people = `[ + { id: 'u1', label: 'Maya Chen', type: 'user' }, + { id: 'u2', label: 'Arjun Rao', type: 'user' }, + { id: 'u3', label: 'Dana Whitfield', type: 'user' } +]`; + +export const preview = { + type: 'code', + code: `function EditorPreview() { + const people = ${people}; + + return ( +
+ + + + + + + + + + + + + + + + + + +
+ ); +}` +}; + +export const getCode = (props: ComponentPropsType) => { + const { + toolbar = 'floating', + slashMenu = true, + mentions = true, + ...rest + } = props; + const fixed = + toolbar === 'fixed' || toolbar === 'both' + ? ` + + + + + + + + + + + ` + : ''; + const floating = + toolbar === 'floating' || toolbar === 'both' + ? ` + + + + + + + ` + : ''; + const slash = slashMenu ? '\n ' : ''; + const mention = mentions + ? `\n ` + : ''; + return `${fixed} + ${floating}${slash}${mention} +`; +}; + +export const playground = { + type: 'playground', + controls: { + placeholder: { type: 'text', initialValue: 'Write something…' }, + toolbar: { + type: 'select', + options: ['floating', 'fixed', 'both'], + defaultValue: 'floating' + }, + slashMenu: { type: 'checkbox', defaultValue: true }, + mentions: { type: 'checkbox', defaultValue: true }, + disabled: { type: 'checkbox', defaultValue: false }, + readOnly: { type: 'checkbox', defaultValue: false } + }, + getCode +}; + +export const fixedToolbarDemo = { + type: 'code', + code: `
+ + + + + + + + + + + + + + + + + + + + + + + + + +
` +}; + +export const commentBoxDemo = { + type: 'code', + code: `function CommentBox() { + const editor = React.useRef(null); + const [empty, setEmpty] = React.useState(true); + const [comments, setComments] = React.useState([]); + const people = ${people}; + + const send = () => { + const html = editor.current.getHTML(); + setComments(current => [...current, html]); + editor.current.commands.clear(); + setEmpty(true); + }; + + return ( + + {comments.map((html, index) => ( +
+ ))} + setEmpty(details.empty)} + > + + + + + + + + ); +}` +}; + +export const mentionsDemo = { + type: 'code', + code: `function MentionsDemo() { + const people = ${people}; + const issues = [ + { id: 'ENG-214', label: 'ENG-214 Improve onboarding', type: 'issue' }, + { id: 'ENG-230', label: 'ENG-230 Calendar range', type: 'issue' } + ]; + + const searchIssues = (query, { signal }) => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => + resolve( + issues.filter(issue => + issue.label.toLowerCase().includes(query.toLowerCase()) + ) + ), + 400 + ); + signal.addEventListener('abort', () => { + clearTimeout(timer); + reject(new DOMException('Aborted', 'AbortError')); + }); + }); + + return ( +
+ + + + + +
+ ); +}` +}; + +export const slashItemsDemo = { + type: 'code', + code: `function SlashItems() { + const items = [ + ...defaultSlashItems, + { + id: 'date', + label: "Today's date", + group: 'Insert', + keywords: ['today', 'time'], + icon: , + run: editor => editor.commands.insertText(new Date().toLocaleDateString()) + } + ]; + + return ( +
+ + + + +
+ ); +}` +}; + +export const controlledDemo = { + type: 'code', + code: `function ControlledEditor() { + const [value, setValue] = React.useState(${sampleDoc.replace(/\n/g, '\n ')}); + const [html, setHtml] = React.useState(''); + + return ( + + { + setValue(next); + setHtml(details.getHTML()); + }} + > + + + + + + + + + {html || 'Edit the text to see its HTML.'} + + + + ); +}` +}; + +export const markdownDemo = { + type: 'code', + code: `function MarkdownEditor() { + const adapter = React.useMemo(() => MarkdownAdapter.create(), []); + const [markdown, setMarkdown] = React.useState( + '## Notes\\n\\nPaste **Markdown** here, or use _shortcuts_ like \`- \` and \`## \`.\\n\\n- [x] Load Markdown\\n- [ ] Save Markdown' + ); + + return ( + + setMarkdown(details.getMarkdown())} + > + + + + + + {markdown} + + + + ); +}` +}; + +export const readOnlyDemo = { + type: 'code', + code: `
+ + + + + + +
` +}; + +export const customControlDemo = { + type: 'code', + code: `function ClearFormattingButton() { + const editor = useEditor(); + const canClear = useEditorState(() => editor.can.clearFormatting()); + + return ( + event.preventDefault()} + onClick={() => editor.commands.clearFormatting()} + > + Clear + + ); +} + +render( +
+ + + + + + + + + +
+);` +}; diff --git a/apps/www/src/content/docs/components/editor/index.mdx b/apps/www/src/content/docs/components/editor/index.mdx new file mode 100644 index 000000000..f737f5d1e --- /dev/null +++ b/apps/www/src/content/docs/components/editor/index.mdx @@ -0,0 +1,272 @@ +--- +title: Editor +description: A rich text editor with a floating toolbar, a fixed toolbar, a slash menu and mentions, built on ProseMirror. +source: packages/raystack/components/editor +tag: new +--- + +import { preview, playground, fixedToolbarDemo, commentBoxDemo, mentionsDemo, slashItemsDemo, controlledDemo, markdownDemo, readOnlyDemo, customControlDemo } from "./demo.ts"; + + + +## Anatomy + +Import and assemble the editor. The root owns the editor state, so a toolbar can sit anywhere inside it, above or below the content. + +```tsx +import { Editor, Toolbar } from '@raystack/apsara' + + + + + + + + + + + + + + + + + + +``` + +The controls work the same in `Editor.Toolbar` and `Editor.FloatingToolbar`, so you build either toolbar from the same parts. Use `Toolbar.Group` and `Toolbar.Separator` to group them. + +## Playground + + + +## API Reference + +### Root + +Groups all parts and owns the editor state. Renders a `div` with `data-focused`, `data-empty`, `data-disabled` and `data-readonly`. + + + +### Content + +The editable document. It renders the ProseMirror view with `role="textbox"` and `aria-multiline`. Give it an accessible name with `aria-label`. + + + +### Toolbar + +A toolbar that stays in place. It renders an Apsara `Toolbar` and takes its props. It does not render when the editor is read only. + + + +### FloatingToolbar + +A toolbar that shows above a text selection while the editor has focus. It waits for the mouse button to come up, and it shows at once for a keyboard selection. Escape hides it until the selection changes. + + + +### MarkButton + +Toggles a mark. The button is pressed when the selection has the mark, and it is disabled where the mark is not allowed, for example in a code block. + + + +### BlockButton + +Toggles a quote, a code block or a list, or inserts a divider. + + + +### HeadingMenu + +A menu with regular text and the headings. The trigger shows the current style, and each row shows its shortcut. + + + +### ListMenu + +A menu with the list types. The trigger icon follows the active list. + + + +### LinkButton + +Adds, edits or removes a link. In the floating toolbar the URL field replaces the buttons. In the fixed toolbar it opens in a popover. `Mod-k` opens the same field. Only `http`, `https`, `mailto` and relative links are allowed. + + + +### HistoryButton + +Undoes or redoes. It is disabled when there is nothing to undo or redo. + + + +### SlashMenu + +A menu of block commands that opens on `/` at the start of a block or after a space. It filters on the label and the keywords, and a space in the query closes it. Picking a command removes the typed `/query`, and one undo brings it back. A built-in command that cannot run at the caret is disabled, for example a heading in the first paragraph of a list item. + + + + + +### Mentions + +A menu that inserts a mention chip. It takes the same props as `PromptInput.Mentions`. Mount one per trigger, for example `@` for people and `#` for issues. + + + + + +### Hooks + +`useEditor()` returns the editor API inside ``. `useEditorState(selector, isEqual?)` selects a value from the ProseMirror state and re-renders only when that value changes. `actionsRef` gives you the same API from outside the editor. + +`editorCommands` has the same commands as ProseMirror commands, for code that works on an `EditorState` directly. For example, `editorCommands.setHeading(2)(state, dispatch)` runs what `editor.commands.setHeading(2)` runs. `commands.insertMention` inserts the chip at the selection. + + + + + +### Change details + +The second argument of `onValueChange`. The methods convert the doc from that change each time you call them, so call a method only when you need its result. + + + +### Markdown + +`MarkdownAdapter.create(options)` returns an adapter for the `markdown` prop. `MarkdownAdapter.toEditor(markdown)` and `MarkdownAdapter.fromEditor(value)` convert without an editor, for example on the server. An app that never imports `MarkdownAdapter` ships no Markdown code. + +Markdown has no form for some content. A hard break at the end of a block is dropped, a hard break in a heading becomes a space, and an empty paragraph is dropped. A controlled Markdown `value` that the current doc converts to does not reload the doc, so these do not change while the user types. + + + +## Examples + +### Fixed toolbar + +A toolbar above the content, like a document editor. + + + +### Comment box + +`formats` limits the nodes and marks, and input rules and paste follow it. `details.empty` gates the submit button, and `actionsRef` reads and clears the editor. + + + +### Mentions + +Two triggers: `@` filters a list, and `#` searches asynchronously. `onSearch` gets an `AbortSignal` for requests that a newer query replaces. + + + +### Custom slash commands + +Spread `defaultSlashItems` and add your own. `run` gets the editor API after the menu removes the typed query. + + + +### Controlled + +`onValueChange` emits editor JSON, and `details.getHTML()` converts it to HTML. Pass the emitted value back to `value`, and the editor keeps its selection. A different `value` replaces the doc and starts a new undo history. + + + +### Markdown + +With the `markdown` prop, `value` can be a Markdown string, pasted plain-text Markdown turns into rich content, and `details.getMarkdown()` returns Markdown. The editor still emits JSON. + + + +### Read only + +`readOnly` renders the content and hides the toolbars and menus. Use `editorToHTML(value)` to render stored content without an editor, for example on the server, and `editorToText(value)` for plain text. + + + +### Custom control + +Build your own control with `useEditor` and `useEditorState`. Prevent the default on mouse down, so a click does not move the selection. + + + +## Data model + +The value is ProseMirror JSON, the output of `doc.toJSON()`. Node and mark names match Tiptap, so content from Tiptap loads as is. + +| Name | Kind | Attributes | Markdown | +|------|------|------------|----------| +| `paragraph` | block | | text | +| `heading` | block | `level`: 1 to 4 | `#` to `####` | +| `blockquote` | block | | `>` | +| `codeBlock` | block | `language` | fenced code | +| `bulletList`, `listItem` | block | | `-` | +| `orderedList` | block | `start` | `1.` | +| `taskList`, `taskItem` | block | `checked` | `- [ ]` | +| `horizontalRule` | block | | `---` | +| `hardBreak` | inline | | trailing `\` | +| `mention` | inline | `id`, `label`, `type`, `trigger` | `@[label](type:id)` | +| `bold`, `italic`, `strike`, `code` | mark | | `**`, `*`, `~~`, `` ` `` | +| `underline` | mark | | `` | +| `link` | mark | `href` | `[text](href)` | + +On load, a node the schema does not have unwraps into its blocks, or becomes a paragraph when it holds only text. A mark the schema or the parent node does not allow is dropped, and a node in the wrong place is wrapped, for example a list item outside a list. + +Typing shortcuts: `#` to `####` and a space make a heading, `-` or `*` a bulleted list, `1.` a numbered list, `[]` a checklist, `>` a quote, ` ``` ` a code block, and `---`, or `***` or `___` and a space, a divider. `**bold**`, `_italic_`, `` `code` `` and `~~strike~~` apply marks. Backspace right after a shortcut undoes it. + +## Keyboard shortcuts + +`Mod` is Cmd on macOS and Ctrl elsewhere. Override or turn off a key with the `shortcuts` prop. Tooltips and menu rows show the key you set. `defaultShortcuts` holds the default keys. + +| Action | Key | +|--------|-----| +| Bold | `Mod-b` | +| Italic | `Mod-i` | +| Underline | `Mod-u` | +| Strikethrough | `Mod-Shift-x` | +| Inline code | `Mod-e` | +| Link | `Mod-k` | +| Text | `Mod-Alt-0` | +| Heading 1 to 4 | `Mod-Alt-1` to `Mod-Alt-4` | +| Bulleted list | `Mod-Shift-8` | +| Numbered list | `Mod-Shift-9` | +| Checklist | `Mod-Shift-7` | +| Quote | `Alt-Shift-.` | +| Code block | `Mod-Shift-\` | +| Undo, redo | `Mod-z`, `Mod-Shift-z` or `Mod-y` | +| Focus the toolbar | `Alt-F10` | + +In a list, Enter splits the item, and Tab and Shift+Tab indent and outdent it. Shift+Enter adds a line break. + +### Slots + +Every rendered part carries a stable `data-slot` attribute for [styling and testing](/docs/styling#with-data-slot): + +| Slot | Element | +|------|---------| +| `editor` | The root element | +| `editor-content` | `Editor.Content` | +| `editor-toolbar` | `Editor.Toolbar` | +| `editor-floating-toolbar` | The toolbar inside `Editor.FloatingToolbar` | +| `editor-mark-button` | `Editor.MarkButton` | +| `editor-block-button` | `Editor.BlockButton` | +| `editor-heading-menu` | The `Editor.HeadingMenu` trigger | +| `editor-list-menu` | The `Editor.ListMenu` trigger | +| `editor-link-button` | `Editor.LinkButton` | +| `editor-link-form` | The URL form of `Editor.LinkButton` | +| `editor-link-input` | The URL field | +| `editor-history-button` | `Editor.HistoryButton` | +| `editor-slash-menu` | The `Editor.SlashMenu` listbox | +| `editor-mention-menu` | The `Editor.Mentions` listbox | + +## Accessibility + +- The content is a `textbox` with `aria-multiline`. While a menu is open it also has `aria-controls` and `aria-activedescendant`, and focus stays in the text. +- The toolbars have `role="toolbar"` and move focus with the arrow keys. `Alt-F10` moves focus from the text to the toolbar. In the floating toolbar, Escape, Tab past either end, and a command that hides the toolbar return focus to the text. +- Toggle buttons use `aria-pressed`. Each control's accessible name is its label, and the tooltip adds the shortcut. +- Heading and list menu rows use `menuitemradio` with `aria-checked`. +- The slash and mention menus use `listbox` and `option` roles. +- Checklist checkboxes are real `input` elements. They are disabled while the editor is read only or disabled. diff --git a/apps/www/src/content/docs/components/editor/props.ts b/apps/www/src/content/docs/components/editor/props.ts new file mode 100644 index 000000000..ab020f449 --- /dev/null +++ b/apps/www/src/content/docs/components/editor/props.ts @@ -0,0 +1,432 @@ +export interface EditorProps { + /** + * Controlled document as editor JSON. With the `markdown` prop, a string is + * parsed as Markdown. + */ + value?: EditorJSON | string; + + /** + * The first document when uncontrolled. Read once. With the `markdown` prop, + * a string is parsed as Markdown. + */ + defaultValue?: EditorJSON | string; + + /** + * Fires once per doc change. It does not fire for changes made through + * `value`, or for labels from `resolveMentions`. The value is always editor + * JSON. + */ + onValueChange?: (value: EditorJSON, details: EditorChangeDetails) => void; + + /** + * A Markdown adapter from `MarkdownAdapter.create()`. Allows Markdown strings + * in `value` and `defaultValue`, parses Markdown on paste, and adds + * `details.getMarkdown()`. + */ + markdown?: MarkdownAdapter; + + /** Shows while the doc is empty. */ + placeholder?: string; + + /** + * Allowlist of nodes and marks. Read once, when the editor is created. + * @defaultValue all formats + */ + formats?: EditorFormat[]; + + /** Overrides a default key in ProseMirror keymap syntax, or turns it off with `false`. Read once. */ + shortcuts?: Partial>; + + /** + * Makes the editor not editable and disables the toolbars. + * @defaultValue false + */ + disabled?: boolean; + + /** + * Makes the editor not editable and hides the toolbars and menus. + * @defaultValue false + */ + readOnly?: boolean; + + /** + * Focuses the editor on mount, with the caret at the start or the end. + * @defaultValue false + */ + autoFocus?: boolean | 'start' | 'end'; + + /** Imperative handle with commands and serializers. */ + actionsRef?: React.RefObject; + + /** Replaces the root element or composes it with another component. */ + render?: React.ReactElement | ((props: object) => React.ReactElement); + + /** Additional CSS class names. */ + className?: string; +} + +export interface EditorContentProps { + /** Accessible name for the textbox. */ + 'aria-label'?: string; + + /** + * Turns the browser's spell check on or off. + * @defaultValue true + */ + spellCheck?: boolean; + + /** Additional CSS class names. */ + className?: string; +} + +export interface EditorToolbarProps { + /** + * Accessible name for the toolbar. + * @defaultValue "Formatting" + */ + 'aria-label'?: string; + + /** + * The orientation of the toolbar layout. + * @defaultValue "horizontal" + */ + orientation?: 'horizontal' | 'vertical'; + + /** Additional CSS class names. */ + className?: string; +} + +export interface EditorFloatingToolbarProps { + /** + * The side of the selection the toolbar shows on. + * @defaultValue "top" + */ + side?: 'top' | 'bottom'; + + /** + * How the toolbar aligns to the selection. + * @defaultValue "start" + */ + align?: 'start' | 'center' | 'end'; + + /** + * Distance from the selection in pixels. + * @defaultValue 8 + */ + sideOffset?: number; + + /** + * Decides whether the toolbar shows for a state. Replaces the default rule: + * a non-empty text selection outside a code block. + */ + shouldShow?: (state: EditorState) => boolean; + + /** + * Accessible name for the toolbar. + * @defaultValue "Formatting" + */ + 'aria-label'?: string; + + /** Additional CSS class names. */ + className?: string; +} + +interface EditorControlProps { + /** Accessible name and tooltip text. Each control has a default. */ + label?: string; + + /** + * Shows the label and shortcut in a tooltip. + * @defaultValue true + */ + tooltip?: boolean; + + /** Replaces the default icon. */ + children?: React.ReactNode; + + /** Additional CSS class names. */ + className?: string; +} + +export interface EditorMarkButtonProps extends EditorControlProps { + /** The mark the button toggles. */ + mark: 'bold' | 'italic' | 'underline' | 'strike' | 'code'; +} + +export interface EditorBlockButtonProps extends EditorControlProps { + /** The block the button toggles. `horizontalRule` inserts a divider. */ + block: + | 'blockquote' + | 'codeBlock' + | 'bulletList' + | 'orderedList' + | 'taskList' + | 'horizontalRule'; +} + +export interface EditorHeadingMenuProps extends EditorControlProps { + /** + * The heading levels in the menu. + * @defaultValue [1, 2, 3, 4] + */ + levels?: Array<1 | 2 | 3 | 4>; +} + +export interface EditorListMenuProps extends EditorControlProps { + /** + * The list types in the menu. + * @defaultValue ["bulletList", "orderedList", "taskList"] + */ + types?: Array<'bulletList' | 'orderedList' | 'taskList'>; +} + +export interface EditorLinkButtonProps extends EditorControlProps {} + +export interface EditorHistoryButtonProps extends EditorControlProps { + /** Which way the button steps through history. */ + action: 'undo' | 'redo'; +} + +export interface EditorSlashMenuProps { + /** + * The commands in the menu. + * @defaultValue defaultSlashItems + */ + items?: EditorSlashItem[]; + + /** + * The character that opens the menu. + * @defaultValue "/" + */ + trigger?: string; + + /** + * Shown when no command matches the query. + * @defaultValue "No results" + */ + emptyMessage?: React.ReactNode; + + /** Observes the menu's open state. */ + onOpenChange?: (open: boolean) => void; +} + +export interface EditorMentionsProps { + /** + * The character that opens the menu. + * @defaultValue "@" + */ + trigger?: string; + + /** Sync data, filtered on the label. */ + items?: EditorMentionItem[]; + + /** + * Async data. Debounced about 150 ms, with superseded requests aborted + * through `signal`. Wins over `items`. + */ + onSearch?: ( + query: string, + context: { trigger: string; signal: AbortSignal } + ) => Promise; + + /** + * Fills in the icon, trailing content and label of chips loaded from + * `value` or `defaultValue`. + */ + resolveMentions?: ( + refs: Array<{ type: string; id: string; label: string }> + ) => Promise; + + /** + * Shown when nothing matches the query. + * @defaultValue "No results" + */ + emptyMessage?: React.ReactNode; + + /** + * Skeleton rows shown while `onSearch` is in flight. + * @defaultValue 3 + */ + loadingRowCount?: number; + + /** Observes the menu's open state. */ + onOpenChange?: (open: boolean) => void; +} + +export interface EditorMentionItem { + id: string; + label: string; + /** + * Entity kind, saved in the mention node. + * @defaultValue "mention" + */ + type?: string; + icon?: React.ReactNode; + /** Trailing content on the row and the chip. */ + trailing?: React.ReactNode; + /** Section heading. Sections render in first-appearance order. */ + group?: string; + disabled?: boolean; + /** Your own data. It is never saved in the document. */ + data?: unknown; +} + +export interface EditorSlashItem { + id: string; + label: string; + /** Shown under the label. The filter matches it at the start of a word. */ + description?: string; + icon?: React.ReactNode; + /** Section heading. Sections keep the order they are declared in. */ + group?: string; + /** Extra words the filter matches. */ + keywords?: string[]; + /** Shortcut shown on the row, in ProseMirror keymap syntax. Built-in commands show their default key. */ + shortcut?: string; + disabled?: boolean; + /** Runs after the menu removes the typed `/query`. */ + run: (editor: EditorApi) => void; +} + +export interface EditorApi { + /** The ProseMirror view. Null until `Editor.Content` mounts. */ + view: EditorView | null; + getState: () => EditorState; + /** Runs a command. Returns true if it applied. */ + commands: EditorCommands; + /** Dry runs a command. Nothing is dispatched. */ + can: EditorCommands; + isActive: (format: EditorFormat, attrs?: Record) => boolean; + getJSON: () => EditorJSON; + getText: () => string; + getHTML: () => string; + /** Null, with a dev console error, when the `markdown` prop is not set. */ + getMarkdown: () => string | null; + focus: (position?: 'start' | 'end') => void; +} + +export interface EditorCommands { + toggleMark: ( + mark: 'bold' | 'italic' | 'underline' | 'strike' | 'code' + ) => boolean; + setParagraph: () => boolean; + setHeading: (level: 1 | 2 | 3 | 4) => boolean; + toggleBlock: (block: 'blockquote' | 'codeBlock') => boolean; + toggleList: (list: 'bulletList' | 'orderedList' | 'taskList') => boolean; + setLink: (href: string) => boolean; + unsetLink: () => boolean; + insertHorizontalRule: () => boolean; + insertText: (text: string) => boolean; + insertMention: ( + item: EditorMentionItem, + options?: { trigger?: string } + ) => boolean; + clearFormatting: () => boolean; + undo: () => boolean; + redo: () => boolean; + setContent: (value: EditorJSON) => boolean; + clear: () => boolean; +} + +export interface EditorChangeDetails { + /** One paragraph that holds only whitespace. */ + empty: boolean; + /** Collects the mentions in the doc, in document order. */ + getMentions: () => EditorMention[]; + /** Converts the doc to plain text. Mentions read as `@label`. */ + getText: () => string; + /** Converts the doc to HTML. */ + getHTML: () => string; + /** Converts the doc to Markdown. Only present with the `markdown` prop. */ + getMarkdown?: () => string; +} + +export interface MarkdownAdapterOptions { + /** + * `"markup"` writes `@[label](type:id)`, which round-trips. `"label"` writes `@label`. + * @defaultValue "markup" + */ + mentions?: 'markup' | 'label'; + + /** + * `"html"` writes `…`. `"drop"` writes plain text. + * @defaultValue "html" + */ + underline?: 'html' | 'drop'; + + /** + * Parses plain-text Markdown on paste. + * @defaultValue true + */ + paste?: boolean; + + /** + * Writes Markdown to `text/plain` on copy. + * @defaultValue false + */ + copy?: boolean; +} + +type EditorFormat = + | 'bold' + | 'italic' + | 'underline' + | 'strike' + | 'code' + | 'link' + | 'heading' + | 'blockquote' + | 'codeBlock' + | 'bulletList' + | 'orderedList' + | 'taskList' + | 'horizontalRule' + | 'mention'; + +type EditorAction = + | 'bold' + | 'italic' + | 'underline' + | 'strike' + | 'code' + | 'link' + | 'paragraph' + | 'heading1' + | 'heading2' + | 'heading3' + | 'heading4' + | 'bulletList' + | 'orderedList' + | 'taskList' + | 'blockquote' + | 'codeBlock' + | 'undo' + | 'redo' + | 'focusToolbar'; + +interface EditorJSON { + type: string; + attrs?: Record; + content?: EditorJSON[]; + marks?: Array<{ type: string; attrs?: Record }>; + text?: string; +} + +interface EditorMention { + id: string; + label: string; + type: string; + trigger: string; + start: number; + end: number; +} + +interface MarkdownAdapter { + toEditor: (markdown: string) => EditorJSON; + fromEditor: (value: EditorJSON) => string; +} + +/** A ProseMirror `EditorState`. */ +type EditorState = Record; +/** A ProseMirror `EditorView`. */ +type EditorView = Record; diff --git a/apps/www/src/content/docs/theme/icons/index.mdx b/apps/www/src/content/docs/theme/icons/index.mdx index 5768cbc59..c3a4298fe 100644 --- a/apps/www/src/content/docs/theme/icons/index.mdx +++ b/apps/www/src/content/docs/theme/icons/index.mdx @@ -61,6 +61,11 @@ the drawing behind them is a choice rather than a shape you asked for: | `StopIcon` | lucide `Square` | | `CalendarIcon` | lucide `CalendarDays` | | `CoPilotIcon` | lucide `Sparkles` | +| `UndoIcon` / `RedoIcon` | lucide `Undo2` / `Redo2` | +| `TextIcon` | lucide `Type` | +| `NumberedListIcon` / `ChecklistIcon` | lucide `ListOrdered` / `ListTodo` | +| `QuoteIcon` | lucide `TextQuote` | +| `CodeBlockIcon` | lucide `SquareCode` | A key is yours to rely on; the drawing behind it can change in a release. So prefer the key that says the job, such as `SortDescendingIcon`, over one that repeats diff --git a/packages/raystack/components/editor/__tests__/bundle.test.ts b/packages/raystack/components/editor/__tests__/bundle.test.ts new file mode 100644 index 000000000..441cb0b71 --- /dev/null +++ b/packages/raystack/components/editor/__tests__/bundle.test.ts @@ -0,0 +1,96 @@ +// @vitest-environment node +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import type { Plugin } from 'rollup'; +import { describe, expect, it } from 'vitest'; + +/* An app that never imports MarkdownAdapter must not ship marked. */ + +const PACKAGE_ROOT = resolve(__dirname, '../../..'); +const PACKAGE_INDEX = join(PACKAGE_ROOT, 'index.tsx'); +const { sideEffects } = JSON.parse( + readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8') +) as { sideEffects?: unknown }; + +/** Resolves `~/` and stubs CSS modules, so the graph is the JavaScript only. */ +const sourcePlugin: Plugin = { + name: 'apsara-source', + resolveId(id, importer) { + if (id.endsWith('.css')) return `\0css:${id}`; + if (id.startsWith('~/')) { + return this.resolve(join(PACKAGE_ROOT, id.slice(2)), importer, { + skipSelf: true + }); + } + return null; + }, + load(id) { + return id.startsWith('\0css:') ? 'export default {};' : null; + } +}; + +async function bundle(source: string): Promise { + const dir = mkdtempSync(join(tmpdir(), 'apsara-editor-bundle-')); + try { + const entry = join(dir, 'fixture.ts'); + writeFileSync(entry, source); + + const { rollup } = await import('rollup'); + const { nodeResolve } = await import('@rollup/plugin-node-resolve'); + const typescript = (await import('@rollup/plugin-typescript')).default; + + const build = await rollup({ + input: entry, + // Libraries stay external, so a kept `marked` shows as an import. + external: id => + !id.startsWith('.') && + !id.startsWith('/') && + !id.startsWith('~') && + !id.startsWith('\0'), + // The package's own `sideEffects` setting, as an app's bundler reads it. + treeshake: { + moduleSideEffects: (_id, external) => external || sideEffects !== false + }, + plugins: [ + sourcePlugin, + nodeResolve({ extensions: ['.ts', '.tsx', '.js'] }), + typescript({ + tsconfig: false, + jsx: 'react-jsx', + target: 'esnext', + module: 'esnext', + moduleResolution: 'bundler', + declaration: false, + skipLibCheck: true, + noEmitOnError: false + }) + ], + onwarn: () => undefined + }); + const { output } = await build.generate({ format: 'es' }); + await build.close(); + return output + .map(chunk => (chunk.type === 'chunk' ? chunk.code : '')) + .join('\n'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe('Markdown bundle cost', () => { + it('ships marked only to an app that imports the adapter', async () => { + const editorOnly = await bundle( + `import { Editor } from ${JSON.stringify(PACKAGE_INDEX)};\n` + + 'export const editor = Editor;\n' + ); + expect(editorOnly).toContain('Editor.Content'); + expect(editorOnly).not.toMatch(/from ['"]marked['"]/); + + const withAdapter = await bundle( + `import { MarkdownAdapter } from ${JSON.stringify(PACKAGE_INDEX)};\n` + + 'export const adapter = MarkdownAdapter.create();\n' + ); + expect(withAdapter).toMatch(/from ['"]marked['"]/); + }, 180_000); +}); diff --git a/packages/raystack/components/editor/__tests__/core.test.ts b/packages/raystack/components/editor/__tests__/core.test.ts new file mode 100644 index 000000000..a7f67b085 --- /dev/null +++ b/packages/raystack/components/editor/__tests__/core.test.ts @@ -0,0 +1,242 @@ +import { AllSelection, EditorState, TextSelection } from 'prosemirror-state'; +import { describe, expect, it } from 'vitest'; +import { setLink, toggleBlock } from '../core/commands'; +import { docFromJSON, type EditorJSON } from '../core/json'; +import { isSafeHref, normalizeHref } from '../core/link'; +import { buildSchema } from '../core/schema'; +import { docToText, editorToHTML, editorToText } from '../core/serializers'; + +const text = (value: string, marks?: EditorJSON['marks']): EditorJSON => ({ + type: 'text', + text: value, + ...(marks ? { marks } : {}) +}); +const paragraph = (...content: EditorJSON[]): EditorJSON => ({ + type: 'paragraph', + ...(content.length ? { content } : {}) +}); +const doc = (...content: EditorJSON[]): EditorJSON => ({ + type: 'doc', + content +}); +const link = (href: string) => ({ type: 'link', attrs: { href } }); + +function apply( + state: EditorState, + command: (s: EditorState, d?: (tr: EditorState['tr']) => void) => boolean +) { + let next = state; + const ran = command(state, tr => { + next = state.apply(tr); + }); + return { ran, json: next.doc.toJSON() as EditorJSON }; +} + +describe('editorToHTML', () => { + it('writes marks, nested content holes and void elements', () => { + expect( + editorToHTML( + doc( + paragraph( + text('a', [{ type: 'bold' }]), + text('b', [{ type: 'bold' }, { type: 'italic' }]), + text('c') + ), + { + type: 'taskList', + content: [ + { + type: 'taskItem', + attrs: { checked: true }, + content: [paragraph(text('Docs'))] + } + ] + } + ) + ) + ).toBe( + '

abc

  • Docs

' + ); + }); + + it('escapes text and attributes, and drops unsafe links', () => { + const html = (href: string, value = 'x') => + editorToHTML(doc(paragraph(text(value, [link(href)])))); + expect(html('/search?q="x"&y', ' & "x"')).toBe( + '

<b> & "x"

' + ); + // A NUL in the href must not let the link text escape the attribute. + expect(html('https://a\u0000b', '" onmouseover="alert(1)" x="')).toBe( + '

" onmouseover="alert(1)" x="

' + ); + expect(html('javascript:alert(1)')).toBe('

x

'); + }); +}); + +describe('editorToText', () => { + it('separates blocks with a blank line and reads mentions as @label', () => { + const value = doc( + { type: 'heading', attrs: { level: 1 }, content: [text('Title')] }, + paragraph( + text('Ask '), + { + type: 'mention', + attrs: { id: 'u1', label: 'Maya', type: 'user', trigger: '@' } + }, + { type: 'hardBreak' }, + text('now') + ) + ); + expect(editorToText(value)).toBe('Title\n\nAsk @Maya\nnow'); + expect(docToText(docFromJSON(buildSchema(), value)).mentions).toEqual([ + { + id: 'u1', + label: 'Maya', + type: 'user', + trigger: '@', + start: 11, + end: 16 + } + ]); + }); +}); + +describe('docFromJSON', () => { + it('unwraps nodes the schema does not have and wraps misplaced ones', () => { + expect( + docFromJSON( + buildSchema(['bold']), + doc( + { + type: 'bulletList', + content: [ + { type: 'listItem', content: [paragraph(text('Buy milk'))] }, + { type: 'listItem', content: [paragraph(text('Call Bob'))] } + ] + }, + paragraph(text('x', [{ type: 'italic' }, { type: 'bold' }])) + ) + ).toJSON() + ).toEqual( + doc( + paragraph(text('Buy milk')), + paragraph(text('Call Bob')), + paragraph(text('x', [{ type: 'bold' }])) + ) + ); + expect( + docFromJSON( + buildSchema(), + doc(text('Hello'), { + type: 'listItem', + content: [paragraph(text('a'))] + }) + ).toJSON() + ).toEqual( + doc(paragraph(text('Hello')), { + type: 'bulletList', + content: [{ type: 'listItem', content: [paragraph(text('a'))] }] + }) + ); + }); + + it('repairs invalid content and never throws', () => { + const load = (value: unknown) => + docFromJSON(buildSchema(), value as EditorJSON).toJSON(); + expect( + load( + doc({ + type: 'codeBlock', + content: [ + text('a', [{ type: 'bold' }]), + { type: 'hardBreak' }, + text('b') + ] + }) + ) + ).toEqual( + doc({ + type: 'codeBlock', + attrs: { language: null }, + content: [text('a\nb')] + }) + ); + expect( + load( + doc({ type: 'heading', attrs: { level: 2.4 }, content: [text('h')] }) + ) + ).toEqual( + doc({ type: 'heading', attrs: { level: 2 }, content: [text('h')] }) + ); + expect( + load(doc(paragraph({ type: 'mention', attrs: { label: 'Maya' } }))) + ).toEqual(doc(paragraph(text('@Maya')))); + expect(load({ type: 'paragraph' })).toEqual(doc(paragraph())); + expect(load({ type: 'doc', content: [null] })).toEqual(doc(paragraph())); + expect(load({ type: 'doc', content: 'text' })).toEqual(doc(paragraph())); + }); +}); + +describe('commands', () => { + it('sets a link after Select All', () => { + const docNode = docFromJSON( + buildSchema(), + doc(paragraph(text('a')), paragraph(text('b'))) + ); + const state = EditorState.create({ + doc: docNode, + selection: new AllSelection(docNode) + }); + expect(apply(state, setLink('a.b'))).toEqual({ + ran: true, + json: doc( + paragraph(text('a', [link('https://a.b')])), + paragraph(text('b', [link('https://a.b')])) + ) + }); + }); + + it('keeps line breaks when a paragraph becomes a code block and back', () => { + const stateOf = (json: EditorJSON) => { + const docNode = docFromJSON(buildSchema(), json); + return EditorState.create({ + doc: docNode, + selection: TextSelection.create(docNode, 1) + }); + }; + const lines = doc(paragraph(text('a'), { type: 'hardBreak' }, text('b'))); + const code = apply(stateOf(lines), toggleBlock('codeBlock')); + expect(code.json).toEqual( + doc({ + type: 'codeBlock', + attrs: { language: null }, + content: [text('a\nb')] + }) + ); + expect(apply(stateOf(code.json), toggleBlock('codeBlock')).json).toEqual( + lines + ); + }); +}); + +describe('links', () => { + it('allows web, mail and relative links, and adds a scheme to bare hosts', () => { + for (const href of ['https://a.b', 'mailto:a@b.c', '/docs']) { + expect(isSafeHref(href), href).toBe(true); + } + for (const href of [ + 'javascript:alert(1)', + ' java\tscript:alert(1)', + 'data:text/html,x', + 'javascript:alert(1)', + 'javascript:alert(1)', + 'javascript:alert(1)' + ]) { + expect(isSafeHref(href), href).toBe(false); + } + expect(normalizeHref('raystack.org')).toBe('https://raystack.org'); + expect(normalizeHref('localhost:3000')).toBe('https://localhost:3000'); + expect(normalizeHref('a@b.co')).toBe('mailto:a@b.co'); + expect(normalizeHref('#top')).toBe('#top'); + }); +}); diff --git a/packages/raystack/components/editor/__tests__/data-slots.test.tsx b/packages/raystack/components/editor/__tests__/data-slots.test.tsx new file mode 100644 index 000000000..21cbdf1d6 --- /dev/null +++ b/packages/raystack/components/editor/__tests__/data-slots.test.tsx @@ -0,0 +1,73 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, it } from 'vitest'; +import { expectSlots, getSlot } from '~/test-utils/data-slots'; +import { Editor } from '../editor'; +import type { EditorApi } from '../editor-types'; +import { contentOf, doc, flush, p, paste, select } from './test-utils'; + +describe('Editor data-slot contract', () => { + it('renders the root, content and fixed toolbar slots', async () => { + const { container } = render( + + + + + + + + + + + + ); + expectSlots(container, [ + 'editor', + 'editor-content', + 'editor-toolbar', + 'editor-history-button', + 'editor-heading-menu', + 'editor-list-menu', + 'editor-block-button', + 'editor-mark-button', + 'editor-link-button' + ]); + + fireEvent.click(screen.getByRole('button', { name: 'Link' })); + await flush(); + expectSlots(document.body, ['editor-link-form', 'editor-link-input']); + }); + + it('renders the floating toolbar and menu slots while they are open', async () => { + const actionsRef = createRef(); + const { container } = render( + + + + + + + + + ); + const content = contentOf(container); + const view = actionsRef.current?.view; + if (!view) throw new Error('no view'); + expect(getSlot(document.body, 'editor-floating-toolbar')).toBeNull(); + expect(getSlot(document.body, 'editor-slash-menu')).toBeNull(); + + act(() => content.focus()); + fireEvent.focus(content); + select(view, 1, 6); + await flush(); + expectSlots(document.body, ['editor-floating-toolbar']); + + select(view, 6, 6); + paste(content, ' /'); + await flush(); + expectSlots(document.body, ['editor-slash-menu']); + paste(content, ' @'); + await flush(); + expectSlots(document.body, ['editor-mention-menu']); + }); +}); diff --git a/packages/raystack/components/editor/__tests__/editor-menus.test.tsx b/packages/raystack/components/editor/__tests__/editor-menus.test.tsx new file mode 100644 index 000000000..73cbd709e --- /dev/null +++ b/packages/raystack/components/editor/__tests__/editor-menus.test.tsx @@ -0,0 +1,211 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import type { EditorMentionItem } from '../core/mention-registry'; +import { Editor } from '../editor'; +import { defaultSlashItems } from '../editor-slash-items'; +import type { EditorApi, EditorSlashItem } from '../editor-types'; +import { + contentOf, + doc, + flush, + p, + paste, + pressKey, + select +} from './test-utils'; + +const PEOPLE: EditorMentionItem[] = [ + { id: 'u1', label: 'Maya Chen', type: 'user', group: 'People' }, + { id: 'u2', label: 'Arjun Rao', type: 'user', group: 'People' }, + { id: 'i1', label: 'ENG-214', type: 'issue', group: 'Issues' } +]; + +function setup( + children: React.ReactNode, + props: Partial[0]> = {} +) { + const actionsRef = createRef(); + const onValueChange = vi.fn(); + const result = render( + + + {children} + + ); + const api = () => { + if (!actionsRef.current) throw new Error('no api'); + return actionsRef.current; + }; + return { + ...result, + api, + onValueChange, + content: contentOf(result.container) + }; +} + +describe('Editor.SlashMenu', () => { + it('opens on "/", sets combobox attributes and filters on keywords', async () => { + const { content } = setup(); + expect(content).toHaveAttribute('aria-autocomplete', 'list'); + paste(content, '/'); + await flush(); + const listbox = screen.getByRole('listbox', { name: 'Commands' }); + expect(screen.getAllByRole('option')).toHaveLength( + defaultSlashItems.length + ); + // `role="textbox"` does not allow `aria-expanded`. + expect(content).not.toHaveAttribute('aria-expanded'); + expect(content).toHaveAttribute('aria-controls', listbox.id); + expect(content.getAttribute('aria-activedescendant')).toBe( + screen.getAllByRole('option')[0].id + ); + paste(content, 'todo'); + await flush(); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('Checklist'); + }); + + it('runs a command on Enter, and one undo brings the query back', async () => { + const { content, api } = setup(); + paste(content, '/h1'); + await flush(); + pressKey(content, 'Enter'); + await flush(); + expect(api().getHTML()).toBe('

'); + expect(screen.queryByRole('listbox')).toBeNull(); + act(() => { + api().commands.undo(); + }); + expect(api().getHTML()).toBe('

/h1

'); + }); + + it('runs a custom item with the editor api and shows its description', async () => { + const run = vi.fn((editor: EditorApi) => { + editor.commands.insertText('today'); + }); + const items: EditorSlashItem[] = [ + { id: 'date', label: 'Date', description: 'Insert a dated line', run }, + { id: 'other', label: 'Other', run: () => undefined } + ]; + const { content, api } = setup(); + paste(content, '/dated'); + await flush(); + const option = screen.getByRole('option'); + expect(option).toHaveTextContent('DateInsert a dated line'); + fireEvent.click(option); + await flush(); + expect(run).toHaveBeenCalledTimes(1); + expect(api().getText()).toBe('today'); + }); + + it('hides built-in commands for formats that are not allowed, for copies too', async () => { + const { content } = setup( + ({ ...item }))} />, + { formats: ['bold', 'bulletList'] } + ); + paste(content, '/'); + await flush(); + expect( + screen.getAllByRole('option').map(option => option.textContent) + ).toEqual(['TextCtrlAlt0', 'Bulleted listCtrlShift8']); + }); + + it('disables a command that cannot run at the caret', async () => { + const { content, api } = setup(, { + defaultValue: doc({ + type: 'bulletList', + content: [{ type: 'listItem', content: [p()] }] + }) + }); + const view = api().view; + if (!view) throw new Error('no view'); + select(view, 3, 3); + paste(content, '/h1'); + await flush(); + expect(screen.getByRole('option', { name: /Heading 1/ })).toHaveAttribute( + 'aria-disabled', + 'true' + ); + pressKey(content, 'Enter'); + await flush(); + expect(api().getText()).toContain('/h1'); + expect(api().isActive('heading')).toBe(false); + }); +}); + +describe('Editor.Mentions', () => { + it('inserts a mention chip', async () => { + const { content, api, onValueChange } = setup( + + ); + paste(content, '@ma'); + await flush(); + pressKey(content, 'Enter'); + await flush(); + expect(api().getJSON()).toEqual( + doc( + p( + { + type: 'mention', + attrs: { id: 'u1', label: 'Maya Chen', type: 'user', trigger: '@' } + }, + ' ' + ) + ) + ); + expect(onValueChange.mock.lastCall?.[1].getMentions()).toEqual([ + { + id: 'u1', + label: 'Maya Chen', + type: 'user', + trigger: '@', + start: 0, + end: 10 + } + ]); + expect(content.querySelector('[data-mention-id="u1"]')).toHaveTextContent( + 'Maya Chen' + ); + }); + + it('supports one menu per trigger', async () => { + const { content } = setup( + <> + + + + ); + paste(content, '#'); + await flush(); + expect(screen.getAllByRole('option')).toHaveLength(1); + expect(screen.getByRole('option')).toHaveTextContent('ENG-214'); + }); + + it('fills in labels from resolveMentions without reporting a change', async () => { + const resolveMentions = vi.fn(async () => [ + { id: 'u1', label: 'Maya Chen', type: 'user' } + ]); + const { content, onValueChange } = setup( + , + { + defaultValue: doc( + p({ + type: 'mention', + attrs: { id: 'u1', label: 'Maya', type: 'user', trigger: '@' } + }) + ) + } + ); + await flush(); + expect(resolveMentions).toHaveBeenCalledWith([ + { id: 'u1', label: 'Maya', type: 'user' } + ]); + expect(content.querySelector('[data-mention-id="u1"]')).toHaveTextContent( + 'Maya Chen' + ); + expect(onValueChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/raystack/components/editor/__tests__/editor-toolbar.test.tsx b/packages/raystack/components/editor/__tests__/editor-toolbar.test.tsx new file mode 100644 index 000000000..1e4a638f8 --- /dev/null +++ b/packages/raystack/components/editor/__tests__/editor-toolbar.test.tsx @@ -0,0 +1,215 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, it } from 'vitest'; +import { Editor } from '../editor'; +import styles from '../editor.module.css'; +import type { EditorApi } from '../editor-types'; +import { contentOf, doc, flush, p, pressKey, select } from './test-utils'; + +function setup() { + const actionsRef = createRef(); + const result = render( + + + + + + + + + ); + const api = () => { + if (!actionsRef.current) throw new Error('no api'); + return actionsRef.current; + }; + const view = () => { + const current = api().view; + if (!current) throw new Error('no view'); + return current; + }; + return { ...result, api, view }; +} + +describe('Editor.Toolbar', () => { + it('toggles a mark, shows it with aria-pressed, and keeps the selection', () => { + const { api, view } = setup(); + select(view(), 1, 6); + const bold = screen.getByRole('button', { name: 'Bold' }); + expect(bold).toHaveAttribute('aria-pressed', 'false'); + expect(bold).toHaveClass(styles.control); + const mouseDown = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true + }); + bold.dispatchEvent(mouseDown); + expect(mouseDown.defaultPrevented).toBe(true); + fireEvent.click(bold); + expect(api().getHTML()).toBe('

Hello

'); + expect(bold).toHaveAttribute('aria-pressed', 'true'); + }); + + it('sets a heading from the menu', async () => { + const { api, view } = setup(); + select(view(), 1, 1); + const trigger = screen.getByRole('button', { name: 'Text style' }); + fireEvent.click(trigger); + await flush(); + // The same element stays the trigger, so the menu keeps its anchor. + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect( + await screen.findByRole('menuitemradio', { name: /Text/ }) + ).toHaveAttribute('aria-checked', 'true'); + fireEvent.click(screen.getByRole('menuitemradio', { name: /Heading 2/ })); + await flush(); + expect(api().getHTML()).toBe('

Hello

'); + }); + + it('applies a link from the link field', async () => { + const { api, view } = setup(); + select(view(), 1, 6); + fireEvent.click(screen.getByRole('button', { name: 'Link' })); + await flush(); + const input = await screen.findByRole('textbox', { name: 'Link URL' }); + fireEvent.change(input, { target: { value: 'raystack.org' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + await flush(); + expect(api().getHTML()).toContain('href="https://raystack.org"'); + }); +}); + +describe('Editor.FloatingToolbar', () => { + function setupFloating() { + const actionsRef = createRef(); + const result = render( +
+ + + + + + + + + +
+ ); + const content = contentOf(result.container); + const view = () => { + const current = actionsRef.current?.view; + if (!current) throw new Error('no view'); + return current; + }; + // Focuses the text and selects "Hello". + const selectWord = async () => { + act(() => content.focus()); + fireEvent.focus(content); + select(view(), 1, 6); + await flush(); + }; + const openLink = async () => { + await selectWord(); + fireEvent.click(screen.getByRole('button', { name: 'Link' })); + await flush(); + return screen.getByRole('textbox', { name: 'Link URL' }); + }; + return { ...result, content, view, selectWord, openLink }; + } + + it('shows for a text selection and hides when it collapses', async () => { + const { view, selectWord } = setupFloating(); + expect(screen.queryByRole('toolbar')).toBeNull(); + await selectWord(); + expect( + screen.getByRole('toolbar', { name: 'Formatting' }) + ).toBeInTheDocument(); + select(view(), 3, 3); + await flush(); + expect(screen.queryByRole('toolbar')).toBeNull(); + }); + + it('waits for the pointer to come up', async () => { + const { content, selectWord } = setupFloating(); + fireEvent.mouseDown(content); + await selectWord(); + expect(screen.queryByRole('toolbar')).toBeNull(); + fireEvent.mouseUp(document); + await flush(); + expect(screen.getByRole('toolbar')).toBeInTheDocument(); + }); + + it('hides on Escape until the selection changes', async () => { + const { view, content, selectWord } = setupFloating(); + await selectWord(); + fireEvent.keyDown(screen.getByRole('button', { name: 'Bold' }), { + key: 'Escape' + }); + await flush(); + expect(screen.queryByRole('toolbar')).toBeNull(); + expect(document.activeElement).toBe(content); + select(view(), 3, 3); + select(view(), 1, 6); + await flush(); + expect(screen.getByRole('toolbar')).toBeInTheDocument(); + }); + + it('takes focus with Alt-F10 and returns it when a command hides the toolbar', async () => { + const { content, selectWord } = setupFloating(); + await selectWord(); + pressKey(content, 'F10', { altKey: true }); + expect(document.activeElement).toBe( + screen.getByRole('button', { name: 'Bold' }) + ); + const codeBlock = screen.getByRole('button', { name: 'Code block' }); + act(() => codeBlock.focus()); + fireEvent.click(codeBlock); + await flush(); + expect(screen.queryByRole('toolbar')).toBeNull(); + expect(document.activeElement).toBe(content); + }); + + it('swaps the buttons for the link field, also with Mod-k', async () => { + const { content, selectWord, openLink } = setupFloating(); + const input = await openLink(); + expect(screen.queryByRole('toolbar')).toBeNull(); + fireEvent.change(input, { target: { value: 'https://raystack.org' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + await flush(); + expect(screen.getByRole('toolbar')).toBeInTheDocument(); + + await selectWord(); + pressKey(content, 'k', { ctrlKey: true }); + await flush(); + expect( + screen.getByRole('textbox', { name: 'Link URL' }) + ).toBeInTheDocument(); + }); + + it('closes the link field on a press outside', async () => { + const { openLink } = setupFloating(); + await openLink(); + const outside = screen.getByRole('button', { name: 'Outside' }); + fireEvent.pointerDown(outside); + fireEvent.mouseDown(outside); + act(() => outside.focus()); + fireEvent.click(outside); + await flush(); + expect(screen.queryByRole('textbox', { name: 'Link URL' })).toBeNull(); + }); + + it('closes the link field and returns to the text when Tab leaves it', async () => { + const { content, openLink } = setupFloating(); + const input = await openLink(); + const guard = Array.from( + document.querySelectorAll('[data-base-ui-focus-guard]') + ).find( + element => + element.compareDocumentPosition(input) & + Node.DOCUMENT_POSITION_PRECEDING + ); + if (!guard) throw new Error('no focus guard after the popup'); + act(() => guard.focus()); + await flush(); + expect(screen.queryByRole('textbox', { name: 'Link URL' })).toBeNull(); + expect(document.activeElement).toBe(content); + }); +}); diff --git a/packages/raystack/components/editor/__tests__/editor.test.tsx b/packages/raystack/components/editor/__tests__/editor.test.tsx new file mode 100644 index 000000000..02c04147c --- /dev/null +++ b/packages/raystack/components/editor/__tests__/editor.test.tsx @@ -0,0 +1,287 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { createRef, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import type { EditorJSON } from '../core/json'; +import { Editor } from '../editor'; +import styles from '../editor.module.css'; +import { useEditor, useEditorState } from '../editor-context'; +import type { EditorApi } from '../editor-types'; +import { + contentOf, + doc, + p, + paste, + pressKey, + select, + typeText +} from './test-utils'; + +function setup(props: Partial[0]> = {}) { + const actionsRef = createRef(); + const onValueChange = vi.fn(); + const result = render( + + + + ); + const api = () => { + if (!actionsRef.current) throw new Error('no api'); + return actionsRef.current; + }; + const view = () => { + const current = api().view; + if (!current) throw new Error('no view'); + return current; + }; + return { + ...result, + api, + view, + onValueChange, + content: contentOf(result.container) + }; +} + +describe('Editor', () => { + it('renders an editable textbox that is empty and shows the placeholder', () => { + const { container, content } = setup({ placeholder: 'Add description…' }); + const root = container.querySelector('[data-slot="editor"]'); + expect(root).toHaveClass(styles.root); + expect(screen.getByRole('textbox', { name: 'Description' })).toBe(content); + expect(content).toHaveAttribute('aria-multiline', 'true'); + expect(content).toHaveAttribute('contenteditable', 'true'); + expect(root).toHaveAttribute('data-empty'); + expect( + content.querySelector('[data-placeholder="Add description…"]') + ).toBeInTheDocument(); + paste(content, 'Hello'); + expect(root).not.toHaveAttribute('data-empty'); + expect(content.querySelector('[data-placeholder]')).toBeNull(); + }); + + it('emits JSON and details on change', () => { + const { content, onValueChange } = setup(); + paste(content, 'Hello'); + expect(onValueChange).toHaveBeenCalledTimes(1); + const [value, details] = onValueChange.mock.calls[0]; + expect(value).toEqual(doc(p('Hello'))); + expect(details.empty).toBe(false); + expect(details.getText()).toBe('Hello'); + expect(details.getHTML()).toBe('

Hello

'); + expect('getMarkdown' in details).toBe(false); + }); + + it('applies a controlled value with a new history and does not report it', () => { + const actionsRef = createRef(); + const onValueChange = vi.fn(); + const { container, rerender } = render( + + + + ); + paste(contentOf(container), '!'); + expect(actionsRef.current?.can.undo()).toBe(true); + onValueChange.mockClear(); + rerender( + + + + ); + expect(actionsRef.current?.getText()).toBe('Two'); + expect(actionsRef.current?.can.undo()).toBe(false); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('keeps the caret when the controlled value is the one it emitted', () => { + function Controlled() { + const [value, setValue] = useState(doc(p())); + return ( + + + + ); + } + const { container } = render(); + const content = contentOf(container); + paste(content, 'Hello'); + paste(content, ' world'); + expect(content).toHaveTextContent('Hello world'); + }); + + it('runs shortcuts, and skips the keys turned off', () => { + const on = setup({ defaultValue: doc(p('Hello')) }); + select(on.view(), 1, 6); + pressKey(on.content, 'b', { ctrlKey: true }); + expect(on.api().getHTML()).toBe('

Hello

'); + act(() => { + on.api().commands.undo(); + }); + pressKey(on.content, 'y', { ctrlKey: true }); + expect(on.api().isActive('bold')).toBe(true); + on.unmount(); + + const off = setup({ + defaultValue: doc(p('Hello')), + shortcuts: { bold: false, redo: false } + }); + select(off.view(), 1, 6); + pressKey(off.content, 'b', { ctrlKey: true }); + expect(off.api().isActive('bold')).toBe(false); + act(() => { + off.api().commands.toggleMark('bold'); + off.api().commands.undo(); + }); + pressKey(off.content, 'y', { ctrlKey: true }); + expect(off.api().isActive('bold')).toBe(false); + }); + + it('applies input rules, and waits for a space after ***', () => { + const cases = [ + ['# ', '

'], + ['- ', '
'], + ['*** ', '

'], + ['***bold', '

***bold

'], + ['**bold**', '

bold

'] + ]; + for (const [input, html] of cases) { + const { view, api, unmount } = setup(); + typeText(view(), input); + expect(api().getHTML(), input).toBe(html); + unmount(); + } + }); + + it('links the selection when a safe URL is pasted over it', () => { + const { view, api, content } = setup({ defaultValue: doc(p('Apsara')) }); + select(view(), 1, 7); + paste(content, 'javascript:alert(1)'); + expect(api().getHTML()).toBe('

javascript:alert(1)

'); + select(view(), 1, 20); + paste(content, 'https://raystack.org'); + expect(api().getHTML()).toBe( + '

javascript:alert(1)

' + ); + }); + + it('follows readOnly and disabled when they change after mount', () => { + const tasks = doc({ + type: 'taskList', + content: [ + { type: 'taskItem', attrs: { checked: true }, content: [p('Docs')] } + ] + }); + const { container, rerender } = render( + + + + ); + const root = container.querySelector('[data-slot="editor"]'); + const content = contentOf(container); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toBeEnabled(); + + rerender( + + + + ); + expect(root).toHaveAttribute('data-readonly'); + expect(content).toHaveAttribute('contenteditable', 'false'); + expect(content).toHaveAttribute('aria-readonly', 'true'); + expect(checkbox).toBeDisabled(); + + rerender( + + + + ); + expect(root).toHaveAttribute('data-disabled'); + expect(content).toHaveAttribute('aria-disabled', 'true'); + expect(checkbox).toBeDisabled(); + + rerender( + + + + ); + expect(content).toHaveAttribute('contenteditable', 'true'); + expect(checkbox).toBeEnabled(); + }); + + it('keeps ProseMirror classes when the content class changes', () => { + const { container, rerender } = render( + + + + ); + const content = contentOf(container); + rerender( + + + + ); + expect(content).toHaveClass('ProseMirror', styles.content, 'second'); + expect(content).not.toHaveClass('first'); + }); + + it('runs commands and dry runs from the api', () => { + const { api, view, onValueChange } = setup({ + defaultValue: doc(p('Item')) + }); + select(view(), 1, 1); + expect(api().can.toggleList('bulletList')).toBe(true); + expect(api().getHTML()).toBe('

Item

'); + expect(api().commands.toggleList('bulletList')).toBe(true); + expect(api().getHTML()).toBe('
  • Item

'); + expect(api().isActive('bulletList')).toBe(true); + act(() => { + api().commands.setContent(doc(p('New'))); + }); + expect(api().getText()).toBe('New'); + expect(onValueChange).toHaveBeenCalledTimes(2); + }); + + it('re-renders a useEditorState reader only when its value changes', () => { + const renders: string[] = []; + function Probe() { + const editor = useEditor(); + const text = useEditorState(state => state.doc.textContent); + renders.push(text); + return ( + + ); + } + const actionsRef = createRef(); + const { container } = render( + + + + + ); + const view = actionsRef.current?.view; + if (!view) throw new Error('no view'); + const before = renders.length; + select(view, 1, 3); + select(view, 3, 3); + expect(renders).toHaveLength(before); + paste(contentOf(container), '!'); + expect(renders[renders.length - 1]).toBe('ab!'); + select(view, 1, 4); + fireEvent.click(screen.getByRole('button', { name: 'ab!' })); + expect(actionsRef.current?.getHTML()).toBe('

ab!

'); + }); +}); diff --git a/packages/raystack/components/editor/__tests__/markdown.test.tsx b/packages/raystack/components/editor/__tests__/markdown.test.tsx new file mode 100644 index 000000000..43d5fdd7a --- /dev/null +++ b/packages/raystack/components/editor/__tests__/markdown.test.tsx @@ -0,0 +1,292 @@ +import { render } from '@testing-library/react'; +import { createRef, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { docFromJSON, type EditorJSON } from '../core/json'; +import { buildSchema } from '../core/schema'; +import { Editor } from '../editor'; +import type { EditorApi } from '../editor-types'; +import { MarkdownAdapter } from '../markdown'; +import { contentOf, copy, paste, pressKey, select } from './test-utils'; + +const t = (text: string, marks?: EditorJSON['marks']): EditorJSON => ({ + type: 'text', + text, + ...(marks ? { marks } : {}) +}); +const para = (...content: EditorJSON[]): EditorJSON => ({ + type: 'paragraph', + ...(content.length ? { content } : {}) +}); +const doc = (...content: EditorJSON[]): EditorJSON => ({ + type: 'doc', + content +}); +const link = (href: string) => [{ type: 'link', attrs: { href } }]; +const mention: EditorJSON = { + type: 'mention', + attrs: { id: 'u1', label: 'Maya', type: 'user', trigger: '@' } +}; +const bullets = (...items: string[]): EditorJSON => ({ + type: 'bulletList', + content: items.map(item => ({ type: 'listItem', content: [para(t(item))] })) +}); +const tasks = (...items: string[]): EditorJSON => ({ + type: 'taskList', + content: items.map(item => ({ + type: 'taskItem', + attrs: { checked: false }, + content: [para(...(item ? [t(item)] : []))] + })) +}); + +/** Loads through the schema, so defaults such as `language: null` are filled in. */ +function normalize(value: EditorJSON): EditorJSON { + return docFromJSON(buildSchema(), value).toJSON() as EditorJSON; +} + +function roundTrip(value: EditorJSON): EditorJSON { + return normalize(MarkdownAdapter.toEditor(MarkdownAdapter.fromEditor(value))); +} + +const EVERYTHING = doc( + { type: 'heading', attrs: { level: 1 }, content: [t('Release notes')] }, + para( + t('Plain '), + t('bold', [{ type: 'bold' }]), + t(' '), + t('italic', [{ type: 'italic' }]), + t(' '), + t('strike', [{ type: 'strike' }]), + t(' '), + t('under', [{ type: 'underline' }]), + t(' '), + t('code', [{ type: 'code' }]), + t(' '), + t('link', link('https://raystack.org')), + t(' and '), + { + type: 'mention', + attrs: { id: 'u_42', label: 'Maya Chen', type: 'user', trigger: '@' } + }, + t(' done.') + ), + para(t('line one'), { type: 'hardBreak' }, t('line two')), + { type: 'blockquote', content: [para(t('Quoted'))] }, + { + type: 'codeBlock', + attrs: { language: 'ts' }, + content: [t('const a = `x`;\nconst b = 2;')] + }, + { type: 'horizontalRule' }, + { + type: 'bulletList', + content: [ + { type: 'listItem', content: [para(t('One')), bullets('Nested')] }, + { type: 'listItem', content: [para(t('Two'))] } + ] + }, + { + type: 'orderedList', + attrs: { start: 3 }, + content: [{ type: 'listItem', content: [para(t('Three'))] }] + }, + { + type: 'taskList', + content: [ + { type: 'taskItem', attrs: { checked: true }, content: [para(t('Done'))] } + ] + }, + para(t('Special *chars* _here_ [x] # not a heading')) +); + +describe('MarkdownAdapter', () => { + it('round-trips every node and mark', () => { + expect(roundTrip(EVERYTHING)).toEqual(normalize(EVERYTHING)); + }); + + it('writes readable Markdown', () => { + expect( + MarkdownAdapter.fromEditor( + doc( + { type: 'heading', attrs: { level: 2 }, content: [t('Title')] }, + para(t('Ship the '), t('fix', [{ type: 'bold' }]), t('.')), + tasks('Docs') + ) + ) + ).toBe('## Title\n\nShip the **fix**.\n\n- [ ] Docs'); + }); + + it('round-trips content that plain Markdown would misread', () => { + const cases: Record = { + 'italic inside a word': doc( + para(t('un'), t('believ', [{ type: 'italic' }]), t('able')) + ), + 'bold that ends in punctuation before a word': doc( + para(t('Note:', [{ type: 'bold' }]), t('text')) + ), + 'leading spaces': doc(para(t(' code?'))), + 'a line that reads as a divider': doc(para(t('---'))), + 'an empty task item': doc(tasks('')), + 'a task list after a bullet list': doc(bullets('x'), tasks('y')), + '"!" before a link': doc( + para(t('Wow!'), t('link', link('https://x.co'))) + ), + 'a mention after punctuation': doc(para(t('('), mention, t(')'))) + }; + for (const [name, value] of Object.entries(cases)) { + expect(roundTrip(value), name).toEqual(normalize(value)); + } + }); + + it('reads links and code after punctuation as links and code', () => { + expect( + normalize( + MarkdownAdapter.toEditor( + '"[a](https://x.com)" ([b](mailto:b@c.co)) `[c](https://d)`' + ) + ) + ).toEqual( + doc( + para( + t('"'), + t('a', link('https://x.com')), + t('" ('), + t('b', link('mailto:b@c.co')), + t(') '), + t('[c](https://d)', [{ type: 'code' }]) + ) + ) + ); + }); + + it('reads character references as CommonMark does', () => { + expect( + MarkdownAdapter.toEditor('� � A `<b>`') + ).toEqual( + doc(para(t('� � A '), t('<b>', [{ type: 'code' }]))) + ); + }); + + it('keeps raw HTML as text and drops unsafe links both ways', () => { + expect( + MarkdownAdapter.toEditor('\n\n[a](javascript:alert(1))') + ).toEqual(doc(para(t('')), para(t('a')))); + expect( + MarkdownAdapter.fromEditor( + doc(para(t('a', link('javascript:alert(1)')))) + ) + ).toBe('a'); + }); +}); + +describe('Editor with the markdown prop', () => { + function setup( + props: Partial[0]> = {}, + options: Parameters[0] = {} + ) { + const actionsRef = createRef(); + const { container } = render( + + + + ); + const api = () => { + if (!actionsRef.current) throw new Error('no api'); + return actionsRef.current; + }; + const view = () => { + const current = api().view; + if (!current) throw new Error('no view'); + return current; + }; + return { api, view, content: contentOf(container) }; + } + + it('loads a Markdown defaultValue and reports getMarkdown', () => { + const onValueChange = vi.fn(); + const { container } = render( + + + + ); + const content = contentOf(container); + expect(content.querySelector('strong')).toHaveTextContent('bold'); + paste(content, '!'); + expect(onValueChange.mock.calls[0][1].getMarkdown()).toBe('**bold** text!'); + }); + + it('parses pasted Markdown, keeps the marks at the caret, and pastes text in code', () => { + const blocks = setup(); + paste(blocks.content, '## Heading\n\n- one\n- two'); + expect(blocks.api().getHTML()).toBe( + '

Heading

  • one

  • two

' + ); + + const bold = setup({ + defaultValue: doc(para(t('Hello', [{ type: 'bold' }]))) + }); + select(bold.view(), 3, 3); + paste(bold.content, 'X'); + expect(bold.api().getJSON()).toEqual( + doc(para(t('HeXllo', [{ type: 'bold' }]))) + ); + + const code = setup({ defaultValue: doc({ type: 'codeBlock' }) }); + paste(code.content, '**x**'); + expect(code.api().getHTML()).toBe('
**x**
'); + }); + + // An empty paragraph has no Markdown, so reloading the string would drop it. + it('keeps the doc when a controlled string is what the doc converts to', () => { + const actionsRef = createRef(); + function Controlled() { + const [markdown, setMarkdown] = useState('Hello'); + return ( + + setMarkdown(MarkdownAdapter.fromEditor(value)) + } + > + + + ); + } + const { container } = render(); + const view = actionsRef.current?.view; + if (!view) throw new Error('no view'); + select(view, 6, 6); + pressKey(contentOf(container), 'Enter'); + expect(actionsRef.current?.getJSON()).toEqual( + doc(para(t('Hello')), para()) + ); + }); + + it('copies Markdown with copy: true', () => { + const { view, content } = setup( + { + defaultValue: doc( + para(t('some '), t('bold', [{ type: 'bold' }]), t(' words')), + bullets('one', 'two') + ) + }, + { copy: true } + ); + select(view(), 1, 16); + expect(copy(content)).toBe('some **bold** words'); + // From inside the first item to inside the second. + select(view(), 21, 29); + expect(copy(content)).toBe('- ne\n- tw'); + }); +}); diff --git a/packages/raystack/components/editor/__tests__/test-utils.tsx b/packages/raystack/components/editor/__tests__/test-utils.tsx new file mode 100644 index 000000000..63f698033 --- /dev/null +++ b/packages/raystack/components/editor/__tests__/test-utils.tsx @@ -0,0 +1,91 @@ +import { act, fireEvent } from '@testing-library/react'; +import { TextSelection } from 'prosemirror-state'; +import type { EditorView } from 'prosemirror-view'; +import { getSlot } from '~/test-utils/data-slots'; +import type { EditorJSON } from '../core/json'; + +export function contentOf(container: ParentNode): HTMLElement { + const node = getSlot(container, 'editor-content'); + if (!node) throw new Error('editor content not found'); + return node; +} + +/** + * Stands in for typing. A paste is the only synthetic text entry that reaches + * a contentEditable ProseMirror view in jsdom. + */ +export function paste(element: HTMLElement, text: string) { + fireEvent.paste(element, { + clipboardData: { + types: ['text/plain'], + files: [], + getData: (kind: string) => (kind === 'text/plain' ? text : '') + } + }); +} + +/** Fires a copy and returns what the editor wrote to `text/plain`. */ +export function copy(element: HTMLElement): string { + const data: Record = {}; + fireEvent.copy(element, { + clipboardData: { + clearData: () => undefined, + setData: (kind: string, value: string) => { + data[kind] = value; + } + } + }); + return data['text/plain'] ?? ''; +} + +/** Types through ProseMirror's text input path, so input rules run. */ +export function typeText(view: EditorView, text: string) { + for (const char of text) { + act(() => { + const { from, to } = view.state.selection; + const handled = view.someProp('handleTextInput', handler => + handler(view, from, to, char, () => + view.state.tr.insertText(char, from, to) + ) + ); + if (!handled) view.dispatch(view.state.tr.insertText(char, from, to)); + }); + } +} + +export function select(view: EditorView, from: number, to: number) { + act(() => { + view.dispatch( + view.state.tr.setSelection(TextSelection.create(view.state.doc, from, to)) + ); + }); +} + +export function pressKey( + element: HTMLElement, + key: string, + init: KeyboardEventInit = {} +) { + fireEvent.keyDown(element, { key, ...init }); +} + +export const flush = async () => { + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); +}; + +export function doc(...content: EditorJSON[]): EditorJSON { + return { type: 'doc', content }; +} + +export function p(...content: Array): EditorJSON { + return { + type: 'paragraph', + content: content.length + ? content.map(child => + typeof child === 'string' ? { type: 'text', text: child } : child + ) + : undefined + }; +} diff --git a/packages/raystack/components/editor/core/base.ts b/packages/raystack/components/editor/core/base.ts new file mode 100644 index 000000000..e36be6377 --- /dev/null +++ b/packages/raystack/components/editor/core/base.ts @@ -0,0 +1,40 @@ +import type { Node as PMNode } from 'prosemirror-model'; +import { type Command, Plugin } from 'prosemirror-state'; +import { Decoration, DecorationSet } from 'prosemirror-view'; +import styles from './editor-core.module.css'; + +/** Marks transactions that came from outside the editor, so they are not reported back. */ +export const EXTERNAL = 'apsara-editor-external'; + +export const insertHardBreak: Command = (state, dispatch) => { + const type = state.schema.nodes.hardBreak; + if (!type) return false; + dispatch?.(state.tr.replaceSelectionWith(type.create()).scrollIntoView()); + return true; +}; + +/** + * Shows `getText()` on the first block while `isEmpty(doc)` holds. A + * ProseMirror paragraph with no text still holds a `
`, so CSS `:empty` + * cannot do this. + */ +export function placeholderPlugin( + getText: () => string | undefined, + isEmpty: (doc: PMNode) => boolean +): Plugin { + return new Plugin({ + props: { + decorations: state => { + const text = getText(); + const first = state.doc.firstChild; + if (!text || !first || !isEmpty(state.doc)) return null; + return DecorationSet.create(state.doc, [ + Decoration.node(0, first.nodeSize, { + class: styles.placeholder, + 'data-placeholder': text + }) + ]); + } + } + }); +} diff --git a/packages/raystack/components/editor/core/commands.ts b/packages/raystack/components/editor/core/commands.ts new file mode 100644 index 000000000..e550657b4 --- /dev/null +++ b/packages/raystack/components/editor/core/commands.ts @@ -0,0 +1,363 @@ +import { setBlockType, toggleMark, wrapIn } from 'prosemirror-commands'; +import { redo, undo } from 'prosemirror-history'; +import type { Mark, MarkType, Node as PMNode } from 'prosemirror-model'; +import { liftListItem, wrapInList } from 'prosemirror-schema-list'; +import { + type Command, + type EditorState, + Selection, + type Transaction +} from 'prosemirror-state'; +import { liftTarget } from 'prosemirror-transform'; +import { isSafeHref, normalizeHref } from './link'; +import { + type EditorHeadingLevel, + type EditorList, + type EditorMark, + LIST_TYPES +} from './schema'; + +export type EditorBlock = + | 'blockquote' + | 'codeBlock' + | EditorList + | 'horizontalRule'; + +const LISTS: readonly string[] = LIST_TYPES; + +/** Runs `build` on a scratch transaction, so a step that throws reads as "cannot run". */ +function tryCommand( + state: EditorState, + dispatch: ((tr: Transaction) => void) | undefined, + build: (tr: Transaction) => boolean +): boolean { + const tr = state.tr; + try { + if (!build(tr)) return false; + } catch { + return false; + } + dispatch?.(tr.scrollIntoView()); + return true; +} + +export function isMarkActive(state: EditorState, mark: string): boolean { + const type = state.schema.marks[mark]; + if (!type) return false; + const { from, to, empty, $from } = state.selection; + if (empty) return !!type.isInSet(state.storedMarks ?? $from.marks()); + return state.doc.rangeHasMark(from, to, type); +} + +/** The nearest list around the selection start, or null. */ +export function activeList(state: EditorState): EditorList | null { + const { $from } = state.selection; + for (let depth = $from.depth; depth > 0; depth -= 1) { + const name = $from.node(depth).type.name; + if (LISTS.includes(name)) return name as EditorList; + } + return null; +} + +export function isBlockquoteActive(state: EditorState): boolean { + const { $from } = state.selection; + for (let depth = $from.depth; depth > 0; depth -= 1) { + if ($from.node(depth).type.name === 'blockquote') return true; + } + return false; +} + +/** `'paragraph'`, a heading level, or null for any other text block. */ +export function activeTextStyle( + state: EditorState +): 'paragraph' | EditorHeadingLevel | null { + const parent = state.selection.$from.parent; + if (parent.type.name === 'paragraph') return 'paragraph'; + if (parent.type.name === 'heading') { + return parent.attrs.level as EditorHeadingLevel; + } + return null; +} + +export function isBlockActive(state: EditorState, block: EditorBlock): boolean { + if (block === 'blockquote') return isBlockquoteActive(state); + if (block === 'codeBlock') { + return state.selection.$from.parent.type.name === 'codeBlock'; + } + if (block === 'horizontalRule') return false; + return activeList(state) === block; +} + +/** The range of the mark of `type` around `pos`, in the text block that holds it. */ +export function markRange( + doc: PMNode, + pos: number, + type: MarkType +): { from: number; to: number; mark: Mark } | null { + const $pos = doc.resolve(pos); + const parent = $pos.parent; + let start = parent.childAfter($pos.parentOffset); + if (!start.node || !type.isInSet(start.node.marks)) { + start = parent.childBefore($pos.parentOffset); + } + if (!start.node) return null; + const mark = type.isInSet(start.node.marks); + if (!mark) return null; + + let startIndex = start.index; + let from = $pos.start() + start.offset; + let endIndex = startIndex + 1; + let to = from + start.node.nodeSize; + while (startIndex > 0 && mark.isInSet(parent.child(startIndex - 1).marks)) { + startIndex -= 1; + from -= parent.child(startIndex).nodeSize; + } + while ( + endIndex < parent.childCount && + mark.isInSet(parent.child(endIndex).marks) + ) { + to += parent.child(endIndex).nodeSize; + endIndex += 1; + } + return { from, to, mark }; +} + +/** The `href` of the link at the selection, or null. */ +export function activeLink(state: EditorState): string | null { + const type = state.schema.marks.link; + if (!type) return null; + const { from, to, empty } = state.selection; + if (empty) { + return ( + (markRange(state.doc, from, type)?.mark.attrs.href as string) ?? null + ); + } + let href: string | null = null; + state.doc.nodesBetween(from, to, node => { + if (href) return false; + const mark = type.isInSet(node.marks); + if (mark) href = mark.attrs.href as string; + return true; + }); + return href; +} + +export function toggleMarkCommand(mark: EditorMark): Command { + return (state, dispatch) => { + const type = state.schema.marks[mark]; + if (!type) return false; + return toggleMark(type)(state, dispatch); + }; +} + +export const setParagraph: Command = (state, dispatch) => { + const type = state.schema.nodes.paragraph; + return setBlockType(type)(state, dispatch); +}; + +export function setHeading(level: EditorHeadingLevel): Command { + return (state, dispatch) => { + const type = state.schema.nodes.heading; + if (!type) return false; + return setBlockType(type, { level })(state, dispatch); + }; +} + +export function toggleBlock(block: 'blockquote' | 'codeBlock'): Command { + return (state, dispatch) => { + const type = state.schema.nodes[block]; + if (!type) return false; + + if (block === 'codeBlock') { + if (state.selection.$from.parent.type === type) { + return setParagraph(state, dispatch); + } + return setBlockType(type)(state, dispatch); + } + + if (isBlockquoteActive(state)) { + const { $from, $to } = state.selection; + const range = $from.blockRange($to, node => node.type === type); + const target = range ? liftTarget(range) : null; + if (!range || target == null) return false; + dispatch?.(state.tr.lift(range, target).scrollIntoView()); + return true; + } + return wrapIn(type)(state, dispatch); + }; +} + +export function toggleList(list: EditorList): Command { + return (state, dispatch) => { + const listType = state.schema.nodes[list]; + const itemType = + state.schema.nodes[list === 'taskList' ? 'taskItem' : 'listItem']; + if (!listType || !itemType) return false; + + const { $from, $to } = state.selection; + const range = $from.blockRange($to, node => LISTS.includes(node.type.name)); + + if (!range) return wrapInList(listType)(state, dispatch); + + const current = range.parent; + if (current.type === listType) + return liftListItem(itemType)(state, dispatch); + + // A different kind of list: rebuild it in place. The items keep their + // content and size, so every position inside stays valid. + return tryCommand(state, dispatch, tr => { + const pos = range.$from.before(range.depth); + const items: PMNode[] = []; + current.forEach(item => { + items.push( + item.type === itemType + ? item + : itemType.create( + list === 'taskList' ? { checked: false } : null, + item.content + ) + ); + }); + tr.replaceWith(pos, pos + current.nodeSize, listType.create(null, items)); + tr.setSelection(Selection.fromJSON(tr.doc, state.selection.toJSON())); + return true; + }); + }; +} + +/** + * Whether a link can go on the selection: the text block at a caret, or some + * text in a range, allows the link mark. + */ +export function canSetLink(state: EditorState): boolean { + const type = state.schema.marks.link; + if (!type) return false; + const { empty, $from, ranges } = state.selection; + if (empty) { + return $from.parent.inlineContent && $from.parent.type.allowsMarkType(type); + } + return ranges.some(range => { + let applies = false; + state.doc.nodesBetween(range.$from.pos, range.$to.pos, node => { + if (applies) return false; + applies = node.inlineContent && node.type.allowsMarkType(type); + return true; + }); + return applies; + }); +} + +export function setLink(href: string): Command { + return (state, dispatch) => { + const type = state.schema.marks.link; + if (!type || !canSetLink(state)) return false; + const normalized = normalizeHref(href); + if (!normalized || !isSafeHref(normalized)) return false; + const { from, to, empty } = state.selection; + + return tryCommand(state, dispatch, tr => { + const mark = type.create({ href: normalized }); + if (!empty) { + tr.removeMark(from, to, type).addMark(from, to, mark); + return true; + } + const range = markRange(state.doc, from, type); + if (range) { + tr.removeMark(range.from, range.to, type).addMark( + range.from, + range.to, + mark + ); + return true; + } + tr.insertText(normalized, from); + tr.addMark(from, from + normalized.length, mark); + return true; + }); + }; +} + +export const unsetLink: Command = (state, dispatch) => { + const type = state.schema.marks.link; + if (!type) return false; + const { from, to, empty } = state.selection; + if (!empty) { + if (!state.doc.rangeHasMark(from, to, type)) return false; + dispatch?.(state.tr.removeMark(from, to, type)); + return true; + } + const range = markRange(state.doc, from, type); + if (!range) return false; + dispatch?.(state.tr.removeMark(range.from, range.to, type)); + return true; +}; + +export const insertHorizontalRule: Command = (state, dispatch) => { + const type = state.schema.nodes.horizontalRule; + if (!type) return false; + const paragraph = state.schema.nodes.paragraph; + + return tryCommand(state, dispatch, tr => { + tr.deleteSelection(); + const $pos = tr.doc.resolve(tr.selection.from); + if (!$pos.parent.isTextblock) return false; + const rule = type.create(); + + if ($pos.parent.content.size === 0 || $pos.parentOffset === 0) { + tr.insert($pos.before(), rule); + return true; + } + if ($pos.parentOffset === $pos.parent.content.size) { + const after = $pos.after(); + tr.insert(after, [rule, paragraph.create()]); + tr.setSelection( + Selection.near(tr.doc.resolve(after + rule.nodeSize + 1)) + ); + return true; + } + tr.split($pos.pos); + tr.insert($pos.pos + 1, rule); + return true; + }); +}; + +export function insertText(text: string): Command { + return (state, dispatch) => { + dispatch?.(state.tr.insertText(text).scrollIntoView()); + return true; + }; +} + +export const clearFormatting: Command = (state, dispatch) => { + const { from, to, empty, $from } = state.selection; + if (empty) { + const marks = state.storedMarks ?? $from.marks(); + if (marks.length === 0) return false; + dispatch?.(state.tr.setStoredMarks([])); + return true; + } + let hasMarks = false; + state.doc.nodesBetween(from, to, node => { + if (node.marks.length) hasMarks = true; + return !hasMarks; + }); + if (!hasMarks) return false; + dispatch?.(state.tr.removeMark(from, to)); + return true; +}; + +/** The editor's commands as ProseMirror commands, for code that works on an `EditorState` directly. */ +export const editorCommands = { + toggleMark: toggleMarkCommand, + setParagraph: (): Command => setParagraph, + setHeading, + toggleBlock, + toggleList, + setLink, + unsetLink: (): Command => unsetLink, + insertHorizontalRule: (): Command => insertHorizontalRule, + insertText, + clearFormatting: (): Command => clearFormatting, + undo: (): Command => undo, + redo: (): Command => redo +}; diff --git a/packages/raystack/components/editor/markup.ts b/packages/raystack/components/editor/core/composer/markup.ts similarity index 75% rename from packages/raystack/components/editor/markup.ts rename to packages/raystack/components/editor/core/composer/markup.ts index c9b36c6bb..42a493240 100644 --- a/packages/raystack/components/editor/markup.ts +++ b/packages/raystack/components/editor/core/composer/markup.ts @@ -3,10 +3,12 @@ import { type EditorMention, isTriggerCharacter, type MentionAttrs, + mentionText, + readMention, serializeMention -} from './mention'; +} from '../mention'; import { - editorSchema, + composerSchema, hardBreakType, mentionType, paragraphType @@ -23,72 +25,13 @@ export interface EditorDocDetails { empty: boolean; } -interface MentionMatch { - attrs: MentionAttrs; - /** Index just past the closing `)`. */ - next: number; -} - -/** - * Reads `X[label](type:id)` at `start`, where `X` is the trigger. Returns null - * for anything malformed so the caller can keep the characters as literal text. - */ -function readMention(source: string, start: number): MentionMatch | null { - const trigger = source[start]; - if (!isTriggerCharacter(trigger) || source[start + 1] !== '[') return null; - - let index = start + 2; - let label = ''; - while (index < source.length) { - const char = source[index]; - if (char === '\\' && index + 1 < source.length) { - label += source[index + 1]; - index += 2; - continue; - } - if (char === ']') break; - if (char === '\n') return null; - label += char; - index += 1; - } - if (source[index] !== ']' || source[index + 1] !== '(') return null; - index += 2; - - let type = ''; - let id = ''; - let separated = false; - while (index < source.length) { - const char = source[index]; - if (char === '\\' && index + 1 < source.length) { - if (separated) id += source[index + 1]; - else type += source[index + 1]; - index += 2; - continue; - } - if (char === ')') break; - if (char === '\n') return null; - if (char === ':' && !separated) { - separated = true; - index += 1; - continue; - } - if (separated) id += char; - else type += char; - index += 1; - } - if (source[index] !== ')') return null; - if (!separated || !label || !type || !id) return null; - - return { attrs: { id, label, type, trigger }, next: index + 1 }; -} - /** Inline content for a plain string, where newlines become hard breaks. */ export function inlineFragmentFromText(text: string): Fragment { const nodes: PMNode[] = []; const lines = text.split('\n'); lines.forEach((line, index) => { if (index > 0) nodes.push(hardBreakType.create()); - if (line) nodes.push(editorSchema.text(line)); + if (line) nodes.push(composerSchema.text(line)); }); return Fragment.fromArray(nodes); } @@ -105,7 +48,7 @@ export function docFromMarkup(markup: string): PMNode { const flush = () => { if (!literal) return; - nodes.push(editorSchema.text(literal)); + nodes.push(composerSchema.text(literal)); literal = ''; }; @@ -134,7 +77,7 @@ export function docFromMarkup(markup: string): PMNode { } flush(); - return editorSchema.topNodeType.create( + return composerSchema.topNodeType.create( null, paragraphType.create(null, Fragment.fromArray(nodes)) ); @@ -142,7 +85,7 @@ export function docFromMarkup(markup: string): PMNode { /** A document holding a plain string, with no markup interpretation at all. */ export function docFromText(text: string): PMNode { - return editorSchema.topNodeType.create( + return composerSchema.topNodeType.create( null, paragraphType.create(null, inlineFragmentFromText(text)) ); @@ -157,7 +100,7 @@ export function deriveDocDetails(doc: PMNode): EditorDocDetails { doc.descendants(node => { if (node.type === mentionType) { const attrs = node.attrs as MentionAttrs; - const label = `${attrs.trigger}${attrs.label}`; + const label = mentionText(attrs); mentions.push({ ...attrs, start: text.length, @@ -195,7 +138,7 @@ export function textFromFragment(fragment: Fragment): string { content.forEach(node => { if (node.type === mentionType) { const attrs = node.attrs as MentionAttrs; - text += `${attrs.trigger}${attrs.label}`; + text += mentionText(attrs); return; } if (node.type === hardBreakType) { diff --git a/packages/raystack/components/editor/core/composer/schema.ts b/packages/raystack/components/editor/core/composer/schema.ts new file mode 100644 index 000000000..b88be844f --- /dev/null +++ b/packages/raystack/components/editor/core/composer/schema.ts @@ -0,0 +1,25 @@ +import { Schema } from 'prosemirror-model'; +import { + hardBreakNodeSpec, + mentionNodeSpec, + paragraphNodeSpec +} from '../nodes'; + +/** + * The PromptInput composer: one paragraph of text, hard breaks, and atomic + * mentions. Nothing else, so pasted HTML sanitizes to plain text for free, + * there is no mark or block the parser could keep. + */ +export const composerSchema = new Schema({ + nodes: { + doc: { content: 'paragraph' }, + paragraph: paragraphNodeSpec, + text: { group: 'inline' }, + hardBreak: hardBreakNodeSpec, + mention: mentionNodeSpec + } +}); + +export const mentionType = composerSchema.nodes.mention; +export const hardBreakType = composerSchema.nodes.hardBreak; +export const paragraphType = composerSchema.nodes.paragraph; diff --git a/packages/raystack/components/editor/use-editor.ts b/packages/raystack/components/editor/core/composer/use-composer-editor.ts similarity index 79% rename from packages/raystack/components/editor/use-editor.ts rename to packages/raystack/components/editor/core/composer/use-composer-editor.ts index 8ce687218..ae41d1bae 100644 --- a/packages/raystack/components/editor/use-editor.ts +++ b/packages/raystack/components/editor/core/composer/use-composer-editor.ts @@ -4,16 +4,24 @@ import { baseKeymap } from 'prosemirror-commands'; import { history, redo, undo } from 'prosemirror-history'; import { keymap } from 'prosemirror-keymap'; import { Slice } from 'prosemirror-model'; -import { - type Command, - EditorState, - Plugin, - Selection, - TextSelection -} from 'prosemirror-state'; -import { Decoration, DecorationSet, EditorView } from 'prosemirror-view'; +import { EditorState, Plugin, Selection } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import styles from './editor.module.css'; +import { EXTERNAL, insertHardBreak, placeholderPlugin } from '../base'; +import { escapeText } from '../html'; +import { type MentionAttrs, mentionKey } from '../mention'; +import { deleteAdjacentMention, moveOverMention } from '../mention-commands'; +import { + MentionNodeView, + type MentionPortal, + type MentionPortalRegistry +} from '../mention-node-view'; +import { + dismissSuggestion, + insertMention as insertMentionAt, + type SuggestionState, + suggestionPlugin +} from '../suggestion-plugin'; import { deriveDocDetails, docFromMarkup, @@ -25,24 +33,9 @@ import { textFromFragment, textLength } from './markup'; -import { type MentionAttrs, mentionKey } from './mention'; -import { - MentionNodeView, - type MentionPortal, - type MentionPortalRegistry -} from './mention-node-view'; -import { hardBreakType, mentionType } from './schema'; -import { - dismissSuggestion, - insertMention as insertMentionAt, - type SuggestionState, - suggestionPlugin -} from './suggestion-plugin'; +import { mentionType } from './schema'; -/** Marks transactions that came from outside the editor, so they are not echoed back. */ -const EXTERNAL = 'apsara-editor-external'; - -export interface UseEditorOptions { +export interface UseComposerEditorOptions { /** Markup for the first document. Read once. */ initialMarkup: string; /** Placeholder shown while the document is empty. */ @@ -65,7 +58,7 @@ export interface UseEditorOptions { ) => boolean; } -export interface EditorActions { +export interface ComposerEditorActions { focus: () => void; /** * Replaces the document when `markup` differs from what the document already @@ -83,7 +76,7 @@ export interface EditorActions { dismissSuggestion: () => void; } -export interface UseEditorResult { +export interface UseComposerEditorResult { /** Attach to the element that becomes the editing host. */ hostRef: (node: HTMLDivElement | null) => void; /** @@ -93,66 +86,12 @@ export interface UseEditorResult { initialHtml: { __html: string }; viewRef: React.RefObject; mentionPortals: MentionPortal[]; - actions: EditorActions; -} - -function escapeHtml(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>'); -} - -/** Backspace/Delete take out the whole chip rather than selecting it first. */ -function deleteAdjacentMention(direction: -1 | 1): Command { - return (state, dispatch) => { - if (!state.selection.empty) return false; - const $pos = state.doc.resolve(state.selection.from); - const node = direction === -1 ? $pos.nodeBefore : $pos.nodeAfter; - if (!node || node.type !== mentionType) return false; - if (dispatch) { - const from = direction === -1 ? $pos.pos - node.nodeSize : $pos.pos; - dispatch(state.tr.delete(from, from + node.nodeSize)); - } - return true; - }; -} - -/** - * Arrow keys step over a chip in one press. ProseMirror's default for a - * selectable inline atom is to make it a NodeSelection first, which puts a - * selection ring on the chip on the way past it, a stop the user never asked - * for while moving the caret through a sentence. Clicking a chip still selects - * it, which is where the ring belongs. - */ -function moveOverMention(direction: -1 | 1): Command { - return (state, dispatch) => { - if (!state.selection.empty) return false; - const $pos = state.doc.resolve(state.selection.from); - const node = direction === -1 ? $pos.nodeBefore : $pos.nodeAfter; - if (!node || node.type !== mentionType) return false; - if (dispatch) { - const target = $pos.pos + direction * node.nodeSize; - dispatch( - state.tr - .setSelection(TextSelection.create(state.doc, target)) - .scrollIntoView() - ); - } - return true; - }; + actions: ComposerEditorActions; } -const insertHardBreak: Command = (state, dispatch) => { - if (dispatch) { - dispatch( - state.tr.replaceSelectionWith(hardBreakType.create()).scrollIntoView() - ); - } - return true; -}; - -export function useEditor(options: UseEditorOptions): UseEditorResult { +export function useComposerEditor( + options: UseComposerEditorOptions +): UseComposerEditorResult { const optionsRef = useRef(options); optionsRef.current = options; @@ -165,7 +104,7 @@ export function useEditor(options: UseEditorOptions): UseEditorResult { const initialMarkupRef = useRef(options.initialMarkup); const initialHtml = useMemo( () => ({ - __html: escapeHtml(serializeText(docFromMarkup(initialMarkupRef.current))) + __html: escapeText(serializeText(docFromMarkup(initialMarkupRef.current))) }), [] ); @@ -214,21 +153,6 @@ export function useEditor(options: UseEditorOptions): UseEditorResult { } }); - const placeholderPlugin = new Plugin({ - props: { - decorations(state) { - const text = optionsRef.current.placeholder; - if (!text || !isDocEmpty(state.doc)) return null; - return DecorationSet.create(state.doc, [ - Decoration.node(0, state.doc.content.size, { - class: styles.placeholder, - 'data-placeholder': text - }) - ]); - } - } - }); - const clipboardPlugin = new Plugin({ props: { transformPasted: slice => flattenToInlineSlice(slice), @@ -320,7 +244,8 @@ export function useEditor(options: UseEditorOptions): UseEditorResult { plugins: [ // First in the list, so an open menu wins ↑ ↓ Enter Tab Escape. suggestionPlugin({ - getTriggers: () => optionsRef.current.getTriggers?.() ?? [], + getTriggers: () => + (optionsRef.current.getTriggers?.() ?? []).map(char => ({ char })), onStateChange: next => optionsRef.current.onSuggestionChange?.(next), onKeyDown: (event, suggestion) => optionsRef.current.onSuggestionKeyDown?.(event, suggestion) ?? false @@ -339,7 +264,7 @@ export function useEditor(options: UseEditorOptions): UseEditorResult { keymap(baseKeymap), maxLengthPlugin, scrollPlugin, - placeholderPlugin, + placeholderPlugin(() => optionsRef.current.placeholder, isDocEmpty), clipboardPlugin, focusPlugin ] @@ -399,7 +324,7 @@ export function useEditor(options: UseEditorOptions): UseEditorResult { view.dispatch(view.state.tr.setMeta(EXTERNAL, true)); }, [options.placeholder]); - const actions = useMemo( + const actions = useMemo( () => ({ focus: () => viewRef.current?.focus(), diff --git a/packages/raystack/components/editor/core/editor-core.module.css b/packages/raystack/components/editor/core/editor-core.module.css new file mode 100644 index 000000000..5b5024889 --- /dev/null +++ b/packages/raystack/components/editor/core/editor-core.module.css @@ -0,0 +1,194 @@ +/* The editing host, shared by Editor.Content and PromptInput.Editor. A + contentEditable div grows with its content natively, so there is no + `field-sizing` here, since that property applies to form controls. */ +.editor { + /* One line box has to be tall enough to hold a whole chip, or inserting one + would grow the editor and nudge the page under it. */ + --editor-line-height: var(--rs-line-height-regular); + + box-sizing: border-box; + /* ProseMirror positions widget decorations against the editing host. */ + position: relative; + outline: none; + white-space: pre-wrap; + overflow-wrap: break-word; + word-break: break-word; + line-height: var(--editor-line-height); +} + +/* The PromptInput composer. The small font's own 16px line box is shorter + than a chip's icon and trailing slots, so the composer pairs the small font + with the regular line height and sizes the chip to match, so the line box is + the same height whatever a line holds. */ +.composer { + width: 100%; + overflow-y: auto; + color: var(--rs-color-foreground-base-primary); + font-family: var(--rs-font-family); + font-size: var(--rs-font-size-small); + letter-spacing: var(--rs-letter-spacing-small); +} + +.composer > p { + margin: 0; +} + +/* prosemirror-view ships a stylesheet the editor deliberately does not load, + it brings its own type, layout and selection treatment. Two of those rules + are load-bearing rather than cosmetic, so they are mirrored here. */ + +/* ProseMirror appends a zero-size placeholder after a block that ends + in a `contentEditable="false"` node, a chip, so the browser can draw a + caret past it. An app-level `img { display: block }` reset (Tailwind's + preflight carries one) turns that placeholder into a block box, which reads + as a phantom empty line under the chip and swallows a Backspace. */ +.editor :global(img.ProseMirror-separator) { + display: inline !important; + border: none !important; + margin: 0 !important; +} + +/* A selected chip is a NodeSelection, so the text caret has to go away while + it is up, the way it does for any other selected object. */ +.editor:global(.ProseMirror-hideselection) { + caret-color: transparent; +} + +.editor:global(.ProseMirror-hideselection) *::selection { + background: transparent; +} + +/* A decoration, since a ProseMirror-empty paragraph still holds a trailing +
, so `:empty` would never match. */ +.placeholder::before { + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; + color: var(--rs-color-foreground-base-tertiary); +} + +/* The subtle fill on the typed trigger and its query while the menu is open, so + a pending mention reads as pending rather than as ordinary text. */ +.activeSuggestion { + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); + background: var(--rs-color-background-base-primary-hover); +} + +/* Chip. Chip's tokens on the editor's own type scale, so the line box does not + jump, and inert, since removal is editing-only, so there is no hover or active + treatment to advertise a press that does nothing. */ +.mention { + display: inline-flex; + align-items: center; + gap: var(--rs-space-1); + box-sizing: border-box; + /* Fixed to the line box's height, so nothing the chip holds can change the + composer's height as it lands: an icon portals in one commit after the chip + itself, and a consumer's trailing badge is taller than the label. */ + height: var(--editor-line-height); + max-width: 16rem; + padding: 0 var(--rs-space-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); + background: var(--rs-color-background-base-primary-hover); + color: var(--rs-color-foreground-base-primary); + font-size: inherit; + line-height: inherit; + letter-spacing: inherit; + /* Top-aligned rather than baseline-aligned: an inline-flex box takes its + baseline from its *first* flex item, so a chip with an icon would hang off + the icon's box and ride above the surrounding text. Its height matches the + line box, so aligning the two tops puts the label on the text's baseline. */ + vertical-align: top; + white-space: nowrap; + user-select: none; + cursor: default; +} + +.mentionLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mentionIcon, +.mentionTrailing { + display: inline-flex; + align-items: center; + flex-shrink: 0; + color: var(--rs-color-foreground-base-secondary); +} + +/* The slots are portal targets and stay in the DOM even with nothing in them. */ +.mentionIcon:empty, +.mentionTrailing:empty { + display: none; +} + +.mentionIcon > svg, +.mentionTrailing > svg { + width: var(--rs-space-4); + height: var(--rs-space-4); +} + +/* NodeSelection: clicking a chip selects it as one unit. */ +.mention[data-selected] { + outline: var(--rs-focus-ring); + outline-offset: 1px; +} + +.editor[aria-disabled="true"] .mention { + opacity: 0.7; +} + +/* Suggestion menu. Positioned at the caret but sized to the composer: a caret + is a zero-width anchor, so `--anchor-width` is useless and the width has to + be stated. The guards keep it usable in narrow panels and wide layouts. */ +.suggestionMenu { + box-sizing: border-box; + width: var(--suggestion-menu-width, 20rem); + min-width: 240px; + max-width: min(var(--available-width), 420px); + max-height: 320px; + overflow-y: auto; + padding: var(--rs-space-2); +} + +.suggestionGroup + .suggestionGroup { + margin-top: var(--rs-space-2); +} + +.suggestionGroupLabel { + padding: var(--rs-space-2) var(--rs-space-3); + color: var(--rs-color-foreground-base-tertiary); + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); + font-weight: var(--rs-font-weight-medium); +} + +.suggestionRow { + cursor: pointer; +} + +.suggestionText { + display: flex; + flex-direction: column; + min-width: 0; +} + +.suggestionDescription { + color: var(--rs-color-foreground-base-tertiary); + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); +} + +.suggestionEmpty { + padding: var(--rs-space-3); + color: var(--rs-color-foreground-base-tertiary); +} + +.suggestionLoadingRow { + padding: var(--rs-space-3); +} diff --git a/packages/raystack/components/editor/core/html.ts b/packages/raystack/components/editor/core/html.ts new file mode 100644 index 000000000..4569b4154 --- /dev/null +++ b/packages/raystack/components/editor/core/html.ts @@ -0,0 +1,10 @@ +export function escapeText(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>'); +} + +export function escapeAttribute(value: string): string { + return escapeText(value).replace(/"/g, '"'); +} diff --git a/packages/raystack/components/editor/core/input-rules.ts b/packages/raystack/components/editor/core/input-rules.ts new file mode 100644 index 000000000..85b136cd1 --- /dev/null +++ b/packages/raystack/components/editor/core/input-rules.ts @@ -0,0 +1,133 @@ +import { + InputRule, + inputRules, + textblockTypeInputRule, + wrappingInputRule +} from 'prosemirror-inputrules'; +import type { MarkType, Schema } from 'prosemirror-model'; +import { type Plugin, TextSelection } from 'prosemirror-state'; +import { findWrapping } from 'prosemirror-transform'; + +/** + * `**x**` style rules. The pattern captures the leading boundary in group 1 + * and the text between the delimiters in group 2. The last delimiter + * character is the one being typed, so it is not in the document yet. + */ +function markRule( + pattern: RegExp, + delimiter: string, + type: MarkType +): InputRule { + return new InputRule(pattern, (state, match, start, end) => { + const text = match[2]; + const markStart = start + match[1].length; + const textStart = markStart + delimiter.length; + const textEnd = textStart + text.length; + const tr = state.tr; + tr.delete(textEnd, end); + tr.delete(markStart, textStart); + tr.addMark(markStart, markStart + text.length, type.create()); + tr.removeStoredMark(type); + return tr; + }); +} + +/** Markdown-style typing shortcuts for the formats in `schema`. */ +export function buildInputRules(schema: Schema): Plugin | null { + const { nodes, marks } = schema; + const rules: InputRule[] = []; + + if (nodes.heading) { + rules.push( + textblockTypeInputRule(/^(#{1,4})\s$/, nodes.heading, match => ({ + level: match[1].length + })) + ); + } + if (nodes.blockquote) { + rules.push(wrappingInputRule(/^\s*>\s$/, nodes.blockquote)); + } + if (nodes.taskList && nodes.taskItem) { + const { taskList, taskItem } = nodes; + rules.push( + new InputRule(/^\s*\[( |x)?\]\s$/i, (state, match, start, end) => { + const tr = state.tr.delete(start, end); + const $start = tr.doc.resolve(start); + const range = $start.blockRange(); + const wrapping = range && findWrapping(range, taskList); + if (!range || !wrapping) return null; + const checked = match[1]?.toLowerCase() === 'x'; + tr.wrap( + range, + wrapping.map(wrapper => + wrapper.type === taskItem + ? { type: taskItem, attrs: { checked } } + : wrapper + ) + ); + return tr; + }) + ); + } + if (nodes.bulletList) { + rules.push(wrappingInputRule(/^\s*([-+*])\s$/, nodes.bulletList)); + } + if (nodes.orderedList) { + rules.push( + wrappingInputRule( + /^(\d+)\.\s$/, + nodes.orderedList, + match => ({ start: Number(match[1]) }), + (match, node) => node.childCount + node.attrs.start === Number(match[1]) + ) + ); + } + if (nodes.codeBlock) { + rules.push( + textblockTypeInputRule(/^```([\w-]+)?\s$/, nodes.codeBlock, match => ({ + language: match[1] ?? null + })) + ); + } + if (nodes.horizontalRule) { + const rule = nodes.horizontalRule; + rules.push( + // `***` and `___` wait for a space, so `***bold italic***` can be typed. + new InputRule(/^(?:---|___\s|\*\*\*\s)$/, (state, _match, start, end) => { + const $start = state.doc.resolve(start); + const block = $start.parent; + if (block.type.name !== 'paragraph') return null; + // Only a paragraph that holds nothing but the dashes. + if (end - $start.start() !== block.content.size) return null; + const from = $start.before(); + const to = $start.after(); + const paragraph = state.schema.nodes.paragraph.create(); + const tr = state.tr.replaceWith(from, to, [rule.create(), paragraph]); + return tr.setSelection(TextSelection.create(tr.doc, from + 2)); + }) + ); + } + + if (marks.bold) { + rules.push( + markRule(/(^|\s)\*\*([^*\s](?:[^*]*[^*\s])?)\*\*$/, '**', marks.bold), + markRule(/(^|\s)__([^_\s](?:[^_]*[^_\s])?)__$/, '__', marks.bold) + ); + } + if (marks.italic) { + rules.push( + markRule(/(^|\s)\*([^*\s](?:[^*]*[^*\s])?)\*$/, '*', marks.italic), + markRule(/(^|\s)_([^_\s](?:[^_]*[^_\s])?)_$/, '_', marks.italic) + ); + } + if (marks.strike) { + rules.push( + markRule(/(^|\s)~~([^~\s](?:[^~]*[^~\s])?)~~$/, '~~', marks.strike) + ); + } + if (marks.code) { + rules.push(markRule(/(^|\s)`([^`]+)`$/, '`', marks.code)); + } + + return rules.length ? inputRules({ rules }) : null; +} diff --git a/packages/raystack/components/editor/core/json.ts b/packages/raystack/components/editor/core/json.ts new file mode 100644 index 000000000..8fc5feee2 --- /dev/null +++ b/packages/raystack/components/editor/core/json.ts @@ -0,0 +1,265 @@ +import { + Fragment, + Mark, + type NodeType, + type Node as PMNode, + type Schema +} from 'prosemirror-model'; +import { isSafeHref, stripControlCharacters } from './link'; +import { mentionText } from './mention'; + +/** A ProseMirror document as JSON, the value `Editor` takes and emits. */ +export interface EditorJSON { + type: string; + attrs?: Record; + content?: EditorJSON[]; + marks?: Array<{ type: string; attrs?: Record }>; + text?: string; +} + +export function emptyDoc(schema: Schema): PMNode { + const doc = schema.topNodeType.createAndFill(); + if (!doc) throw new Error('[Apsara] Editor schema has no default block.'); + return doc; +} + +function isNode(value: unknown): value is EditorJSON { + return ( + typeof value === 'object' && + value !== null && + typeof (value as EditorJSON).type === 'string' + ); +} + +function childrenOf(json: EditorJSON): EditorJSON[] { + return Array.isArray(json.content) ? json.content.filter(isNode) : []; +} + +function isInline(schema: Schema, json: EditorJSON): boolean { + return json.type === 'text' || !!schema.nodes[json.type]?.isInline; +} + +/** The text of an inline leaf, with a mention written as `@label`. */ +function leafText(json: EditorJSON): string { + if (typeof json.text === 'string') return json.text; + if (json.type === 'hardBreak') return '\n'; + if (json.type === 'mention') { + const trigger = json.attrs?.trigger; + return mentionText({ + trigger: typeof trigger === 'string' ? trigger : '@', + label: String(json.attrs?.label ?? '') + }); + } + return ''; +} + +/** + * The marks that `parent` allows. An unknown mark is dropped, and so is a + * link with an unsafe `href`. + */ +function marksFor( + schema: Schema, + parent: NodeType, + marks: EditorJSON['marks'] +): readonly Mark[] { + let set = Mark.none; + if (!Array.isArray(marks)) return set; + for (const json of marks) { + const type = isNode(json) ? schema.marks[json.type] : undefined; + if (!type || !parent.allowsMarkType(type)) continue; + let attrs = json.attrs; + if (type.name === 'link') { + const href = json.attrs?.href; + if (typeof href !== 'string' || !isSafeHref(href)) continue; + attrs = { ...attrs, href: stripControlCharacters(href) }; + } + try { + set = type.create(attrs).addToSet(set); + } catch { + // Attributes the mark cannot take. + } + } + return set; +} + +/** + * Inline content for `parent`. A node that `parent` does not allow keeps its + * text, so a hard break in a code block becomes `\n`. + */ +function inlineNodes( + schema: Schema, + parent: NodeType, + content: EditorJSON[] +): PMNode[] { + const nodes: PMNode[] = []; + for (const json of content) { + if (json.type === 'text') { + if (typeof json.text === 'string' && json.text) { + nodes.push( + schema.text(json.text, marksFor(schema, parent, json.marks)) + ); + } + continue; + } + const type = schema.nodes[json.type]; + if (type?.isInline && parent.contentMatch.matchType(type)) { + try { + nodes.push( + type.create(json.attrs, null, marksFor(schema, parent, json.marks)) + ); + continue; + } catch { + // A mention without an `id` or a `label` reads as its text. + } + } + const children = childrenOf(json); + if (children.length) { + nodes.push(...inlineNodes(schema, parent, children)); + continue; + } + const text = leafText(json); + if (text) nodes.push(schema.text(text)); + } + return nodes; +} + +function attrsFor( + type: NodeType, + attrs: EditorJSON['attrs'] +): EditorJSON['attrs'] { + if (type.name === 'heading') { + const level = Math.round(Number(attrs?.level)) || 1; + return { ...attrs, level: Math.min(Math.max(level, 1), 4) }; + } + if (type.name === 'orderedList') { + const start = Number(attrs?.start); + return { + ...attrs, + start: Number.isSafeInteger(start) && start >= 0 ? start : 1 + }; + } + return attrs; +} + +/** Paragraphs that hold the text blocks of `node`, for a node that fits nowhere. */ +function textBlocks(schema: Schema, node: PMNode): PMNode[] { + const paragraph = schema.nodes.paragraph; + if (node.type === paragraph) return []; + if (node.isTextblock) return [paragraph.create(null, node.content)]; + const blocks: PMNode[] = []; + node.descendants(child => { + if (!child.isTextblock) return true; + blocks.push(paragraph.create(null, child.content)); + return false; + }); + return blocks; +} + +/** + * Makes `nodes` fit the content of `parent`. A node that does not fit is + * wrapped, as a list item outside a list gets its list, or else keeps only its + * text. + */ +function fit(schema: Schema, parent: NodeType, nodes: PMNode[]): PMNode[] { + const fitted: PMNode[] = []; + let match = parent.contentMatch; + + const place = (node: PMNode): boolean => { + const fill = match.fillBefore(Fragment.from(node)); + const filled = fill && match.matchFragment(fill.addToEnd(node)); + if (fill && filled) { + fill.forEach(child => { + fitted.push(child); + }); + fitted.push(node); + match = filled; + return true; + } + const wrapping = match.findWrapping(node.type); + if (!wrapping?.length) return false; + let wrapped: PMNode | null = node; + for (let index = wrapping.length - 1; index >= 0 && wrapped; index -= 1) { + wrapped = wrapping[index].createAndFill(null, wrapped); + } + const next = wrapped && match.matchType(wrapped.type); + if (!wrapped || !next) return false; + fitted.push(wrapped); + match = next; + return true; + }; + + for (const node of nodes) { + if (place(node)) continue; + for (const block of textBlocks(schema, node)) place(block); + } + return fitted; +} + +/** + * Block content from JSON. An unknown node unwraps into its blocks, or + * becomes a paragraph when it holds only inline content. Inline nodes at block + * level are wrapped in a paragraph. + */ +function blockNodes(schema: Schema, content: EditorJSON[]): PMNode[] { + const paragraph = schema.nodes.paragraph; + const blocks: PMNode[] = []; + let run: EditorJSON[] = []; + + const flush = () => { + if (run.length === 0) return; + blocks.push(paragraph.create(null, inlineNodes(schema, paragraph, run))); + run = []; + }; + + const visit = (json: EditorJSON) => { + if (isInline(schema, json)) { + run.push(json); + return; + } + const type = schema.nodes[json.type]; + const children = childrenOf(json); + if (!type || type === schema.topNodeType) { + if (children.some(child => !isInline(schema, child))) { + children.forEach(visit); + return; + } + flush(); + blocks.push( + paragraph.create(null, inlineNodes(schema, paragraph, [json])) + ); + return; + } + flush(); + const inner = type.inlineContent + ? inlineNodes(schema, type, children) + : fit(schema, type, blockNodes(schema, children)); + const node = type.createAndFill(attrsFor(type, json.attrs), inner); + if (node) blocks.push(node); + else if (type.inlineContent) blocks.push(paragraph.create(null, inner)); + else for (const child of inner) blocks.push(...textBlocks(schema, child)); + }; + + content.forEach(visit); + flush(); + return blocks; +} + +/** + * Loads JSON into a document of `schema`, repairing what the schema cannot + * hold. Never throws. + */ +export function docFromJSON(schema: Schema, json: EditorJSON): PMNode { + try { + if (!isNode(json) || json.type !== 'doc') return emptyDoc(schema); + const top = schema.topNodeType; + const doc = top.createAndFill( + null, + fit(schema, top, blockNodes(schema, childrenOf(json))) + ); + if (!doc) return emptyDoc(schema); + doc.check(); + return doc; + } catch { + return emptyDoc(schema); + } +} diff --git a/packages/raystack/components/editor/core/keymaps.ts b/packages/raystack/components/editor/core/keymaps.ts new file mode 100644 index 000000000..bc80924f0 --- /dev/null +++ b/packages/raystack/components/editor/core/keymaps.ts @@ -0,0 +1,110 @@ +import { + chainCommands, + exitCode, + newlineInCode, + setBlockType +} from 'prosemirror-commands'; +import { redo } from 'prosemirror-history'; +import { undoInputRule } from 'prosemirror-inputrules'; +import { keymap } from 'prosemirror-keymap'; +import type { Schema } from 'prosemirror-model'; +import { + liftListItem, + sinkListItem, + splitListItem +} from 'prosemirror-schema-list'; +import type { Command, Plugin } from 'prosemirror-state'; +import { insertHardBreak } from './base'; +import { deleteAdjacentMention, moveOverMention } from './mention-commands'; + +const never: Command = () => false; + +function either(...commands: Array): Command { + const present = commands.filter((command): command is Command => !!command); + return present.length ? chainCommands(...present) : never; +} + +const inListItem: Command = state => { + const { $from } = state.selection; + for (let depth = $from.depth; depth > 0; depth -= 1) { + const name = $from.node(depth).type.name; + if (name === 'listItem' || name === 'taskItem') return true; + } + return false; +}; + +/** Backspace at the start of a list item's first paragraph lifts the item. */ +function liftAtItemStart(itemNames: string[]): Command { + return (state, dispatch) => { + const { $from, empty } = state.selection; + if (!empty || $from.parentOffset !== 0 || $from.depth < 2) return false; + const item = $from.node(-1); + if (!itemNames.includes(item.type.name) || $from.index(-1) !== 0) { + return false; + } + return liftListItem(item.type)(state, dispatch); + }; +} + +/** + * Backspace at the start of a heading, or in an empty code block, turns it + * back into a paragraph instead of joining it with the block above. + */ +const resetBlockAtStart: Command = (state, dispatch) => { + const { $from, empty } = state.selection; + if (!empty || $from.parentOffset !== 0) return false; + const parent = $from.parent; + const reset = + parent.type.name === 'heading' || + (parent.type.name === 'codeBlock' && parent.content.size === 0); + if (!reset) return false; + return setBlockType(state.schema.nodes.paragraph)(state, dispatch); +}; + +/** + * Keys for lists, mentions and block edges. Sits after the shortcut keymap + * and before history and the base keymap. `Mod-y` redoes unless `redo` is + * false. + */ +export function buildEditingKeymap( + schema: Schema, + options: { redo: boolean } +): Plugin { + const { listItem, taskItem } = schema.nodes; + const items = [listItem?.name, taskItem?.name].filter( + (name): name is string => !!name + ); + const splitItem = either( + listItem ? splitListItem(listItem) : null, + taskItem ? splitListItem(taskItem, { checked: false }) : null + ); + const sink = either( + listItem ? sinkListItem(listItem) : null, + taskItem ? sinkListItem(taskItem) : null + ); + const lift = either( + listItem ? liftListItem(listItem) : null, + taskItem ? liftListItem(taskItem) : null + ); + + const bindings: Record = { + Enter: splitItem, + 'Shift-Enter': chainCommands(newlineInCode, insertHardBreak), + 'Mod-Enter': exitCode, + // Tab stays in the editor inside a list, and moves focus everywhere else. + Tab: (state, dispatch) => sink(state, dispatch) || inListItem(state), + 'Shift-Tab': (state, dispatch) => + lift(state, dispatch) || inListItem(state), + Backspace: chainCommands( + undoInputRule, + deleteAdjacentMention(-1), + liftAtItemStart(items), + resetBlockAtStart + ), + Delete: deleteAdjacentMention(1), + ArrowLeft: moveOverMention(-1), + ArrowRight: moveOverMention(1) + }; + if (options.redo) bindings['Mod-y'] = redo; + return keymap(bindings); +} diff --git a/packages/raystack/components/editor/core/link.ts b/packages/raystack/components/editor/core/link.ts new file mode 100644 index 000000000..dba485f05 --- /dev/null +++ b/packages/raystack/components/editor/core/link.ts @@ -0,0 +1,56 @@ +const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']); + +const SCHEME = /^([a-z][a-z\d+.-]*:)/i; + +/** A host and port with no scheme, such as `localhost:3000` or `example.com:8080`. */ +const HOST_AND_PORT = /^(?:localhost|[\w-]+(?:\.[\w-]+)+):\d+(?:[/?#]|$)/i; + +const NAMED_REFERENCES: Record = { + colon: ':', + tab: '\t', + newline: '\n' +}; + +/** Decodes the character references a renderer would decode in an `href`. */ +function decodeReferences(href: string): string { + return href.replace( + /&(?:#x([\da-f]+)|#(\d+)|(colon|tab|newline));?/gi, + (_match, hex: string, decimal: string, name: string) => { + if (name) return NAMED_REFERENCES[name.toLowerCase()]; + const code = hex + ? Number.parseInt(hex, 16) + : Number.parseInt(decimal, 10); + return code > 0x10ffff ? '' : String.fromCodePoint(code); + } + ); +} + +/** + * Allows `http`, `https`, `mailto` and relative URLs. Blocks every other + * scheme, such as `javascript:` and `data:`. Browsers ignore whitespace and + * control characters inside a scheme, and a Markdown or HTML renderer decodes + * character references, so both are handled before the check. + */ +export function isSafeHref(href: string): boolean { + // biome-ignore lint/suspicious/noControlCharactersInRegex: the control characters are what is being removed + const compact = decodeReferences(href).replace(/[\u0000- \u007f]/g, ''); + const scheme = compact.match(SCHEME); + if (!scheme) return true; + return SAFE_PROTOCOLS.has(scheme[1].toLowerCase()); +} + +/** Removes control characters, which no valid `href` holds. */ +export function stripControlCharacters(href: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: the control characters are what is being removed + return href.replace(/[\u0000-\u001f\u007f]/g, ''); +} + +/** Adds `https://` to a bare domain, so `example.com` does not become a relative link. */ +export function normalizeHref(input: string): string { + const href = stripControlCharacters(input).trim(); + if (!href) return href; + if (HOST_AND_PORT.test(href)) return `https://${href}`; + if (SCHEME.test(href) || /^[/#?.]/.test(href)) return href; + if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(href)) return `mailto:${href}`; + return `https://${href}`; +} diff --git a/packages/raystack/components/editor/core/mention-commands.ts b/packages/raystack/components/editor/core/mention-commands.ts new file mode 100644 index 000000000..d098e0f39 --- /dev/null +++ b/packages/raystack/components/editor/core/mention-commands.ts @@ -0,0 +1,41 @@ +import { type Command, TextSelection } from 'prosemirror-state'; + +/** Backspace/Delete take out the whole chip rather than selecting it first. */ +export function deleteAdjacentMention(direction: -1 | 1): Command { + return (state, dispatch) => { + if (!state.selection.empty) return false; + const $pos = state.doc.resolve(state.selection.from); + const node = direction === -1 ? $pos.nodeBefore : $pos.nodeAfter; + if (!node || node.type.name !== 'mention') return false; + if (dispatch) { + const from = direction === -1 ? $pos.pos - node.nodeSize : $pos.pos; + dispatch(state.tr.delete(from, from + node.nodeSize)); + } + return true; + }; +} + +/** + * Arrow keys step over a chip in one press. ProseMirror's default for a + * selectable inline atom is to make it a NodeSelection first, which puts a + * selection ring on the chip on the way past it, a stop the user never asked + * for while moving the caret through a sentence. Clicking a chip still selects + * it, which is where the ring belongs. + */ +export function moveOverMention(direction: -1 | 1): Command { + return (state, dispatch) => { + if (!state.selection.empty) return false; + const $pos = state.doc.resolve(state.selection.from); + const node = direction === -1 ? $pos.nodeBefore : $pos.nodeAfter; + if (!node || node.type.name !== 'mention') return false; + if (dispatch) { + const target = $pos.pos + direction * node.nodeSize; + dispatch( + state.tr + .setSelection(TextSelection.create(state.doc, target)) + .scrollIntoView() + ); + } + return true; + }; +} diff --git a/packages/raystack/components/editor/mention-node-view.ts b/packages/raystack/components/editor/core/mention-node-view.ts similarity index 96% rename from packages/raystack/components/editor/mention-node-view.ts rename to packages/raystack/components/editor/core/mention-node-view.ts index b95713778..2668bc125 100644 --- a/packages/raystack/components/editor/mention-node-view.ts +++ b/packages/raystack/components/editor/core/mention-node-view.ts @@ -1,8 +1,7 @@ import type { Node as PMNode } from 'prosemirror-model'; import type { NodeView } from 'prosemirror-view'; -import styles from './editor.module.css'; +import styles from './editor-core.module.css'; import type { MentionAttrs } from './mention'; -import { mentionType } from './schema'; /** * One live chip. The node view owns the element and writes the label into it @@ -79,7 +78,7 @@ export class MentionNodeView implements NodeView { } update(node: PMNode) { - if (node.type !== mentionType) return false; + if (node.type.name !== 'mention') return false; this.write(node.attrs as MentionAttrs); this.registry.update(this.id, node.attrs as MentionAttrs); return true; diff --git a/packages/raystack/components/editor/core/mention-registry.ts b/packages/raystack/components/editor/core/mention-registry.ts new file mode 100644 index 000000000..6ab1163aa --- /dev/null +++ b/packages/raystack/components/editor/core/mention-registry.ts @@ -0,0 +1,149 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { mentionKey } from './mention'; + +export interface EditorMentionItem { + id: string; + label: string; + /** + * Entity kind, serialized into the markup. A single `@` menu legitimately + * returns several kinds, which is why this is per item and not per trigger. + * @defaultValue "mention" + */ + type?: string; + icon?: ReactNode; + /** Trailing metadata: a badge, a shortcut, a timestamp. */ + trailing?: ReactNode; + /** Section heading. Groups render in first-appearance order. */ + group?: string; + disabled?: boolean; + /** Opaque; handed back on submit. Never serialized. */ + data?: unknown; +} + +/** A reference parsed out of markup, before it has been resolved. */ +export interface EditorMentionRef { + type: string; + id: string; + label: string; +} + +/** Everything a `Mentions` part contributes for one trigger. */ +export interface MentionsData { + items?: EditorMentionItem[]; + onSearch?: ( + query: string, + context: { trigger: string; signal: AbortSignal } + ) => Promise; + resolveMentions?: (refs: EditorMentionRef[]) => Promise; + onOpenChange?: (open: boolean) => void; + emptyMessage?: ReactNode; + loadingRowCount?: number; +} + +export interface MentionsConfig extends MentionsData { + trigger: string; +} + +function sameData(a: MentionsData, b: MentionsData): boolean { + return ( + a.items === b.items && + a.onSearch === b.onSearch && + a.resolveMentions === b.resolveMentions && + a.onOpenChange === b.onOpenChange && + a.emptyMessage === b.emptyMessage && + a.loadingRowCount === b.loadingRowCount + ); +} + +/** + * Shared between a `Mentions` part (which writes the config), the part that + * drives the menu and decorates chips, and the root (which reads `data` back + * when assembling a message). PromptInput and Editor each own one. + * + * `icon`, `trailing` and `data` cannot survive serialization, so they live here + * rather than on the document, keyed by `trigger|type|id` and filled in when an + * item is picked from the menu or returned by `resolveMentions`. + * + * Registration is split from data on purpose. The trigger is established once, + * while the data is pushed after every `Mentions` render and compared field by + * field, so an inline `items` array stays live without a config object whose + * identity churns and restarts an in-flight search. + */ +export class MentionRegistry { + private configs = new Map(); + private items = new Map(); + private listeners = new Set<() => void>(); + private revision = 0; + + register(trigger: string): () => void { + if (!this.configs.has(trigger)) { + this.configs.set(trigger, { trigger }); + this.emit(); + } + return () => { + if (this.configs.delete(trigger)) this.emit(); + }; + } + + setData(trigger: string, data: MentionsData): void { + const current = this.configs.get(trigger); + if (!current || sameData(current, data)) return; + this.configs.set(trigger, { trigger, ...data }); + this.emit(); + } + + get(trigger: string): MentionsConfig | undefined { + return this.configs.get(trigger); + } + + triggers(): string[] { + return [...this.configs.keys()]; + } + + remember(trigger: string, item: EditorMentionItem): void { + this.rememberAll(trigger, [item]); + } + + rememberAll(trigger: string, items: EditorMentionItem[]): void { + if (items.length === 0) return; + for (const item of items) { + const type = item.type ?? 'mention'; + this.items.set(mentionKey(trigger, type, item.id), { ...item, type }); + } + this.emit(); + } + + lookup( + trigger: string, + type: string, + id: string + ): EditorMentionItem | undefined { + return this.items.get(mentionKey(trigger, type, id)); + } + + has(trigger: string, type: string, id: string): boolean { + return this.items.has(mentionKey(trigger, type, id)); + } + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + /** + * Bumped by every mutation. Read as a `useSyncExternalStore` snapshot, so a + * reader that mounts after a writer has already emitted still sees the + * change, since `Mentions` registers its trigger in an effect that runs before a + * later sibling `Editor` has subscribed. + */ + getRevision = (): number => this.revision; + + private emit() { + this.revision += 1; + for (const listener of this.listeners) listener(); + } +} diff --git a/packages/raystack/components/editor/mention.ts b/packages/raystack/components/editor/core/mention.ts similarity index 59% rename from packages/raystack/components/editor/mention.ts rename to packages/raystack/components/editor/core/mention.ts index e7217c3d7..1ef20de0e 100644 --- a/packages/raystack/components/editor/mention.ts +++ b/packages/raystack/components/editor/core/mention.ts @@ -29,6 +29,13 @@ export function mentionKey(trigger: string, type: string, id: string): string { return `${trigger}|${type}|${id}`; } +/** A mention as plain text: its trigger and label, as in `@Maya`. */ +export function mentionText( + attrs: Pick +): string { + return `${attrs.trigger}${attrs.label}`; +} + /** * A trigger is a single ASCII punctuation character. `[`, `]` and `\` are * excluded because the dialect uses them as delimiters, and `_` because it @@ -58,6 +65,68 @@ export function serializeMention(attrs: MentionAttrs): string { )}:${escapeRef(attrs.id)})`; } +export interface MentionMatch { + attrs: MentionAttrs; + /** Index just past the closing `)`. */ + next: number; +} + +/** + * Reads `X[label](type:id)` at `start`, where `X` is the trigger. Returns null + * for anything malformed so the caller can keep the characters as literal text. + */ +export function readMention( + source: string, + start: number +): MentionMatch | null { + const trigger = source[start]; + if (!isTriggerCharacter(trigger) || source[start + 1] !== '[') return null; + + let index = start + 2; + let label = ''; + while (index < source.length) { + const char = source[index]; + if (char === '\\' && index + 1 < source.length) { + label += source[index + 1]; + index += 2; + continue; + } + if (char === ']') break; + if (char === '\n') return null; + label += char; + index += 1; + } + if (source[index] !== ']' || source[index + 1] !== '(') return null; + index += 2; + + let type = ''; + let id = ''; + let separated = false; + while (index < source.length) { + const char = source[index]; + if (char === '\\' && index + 1 < source.length) { + if (separated) id += source[index + 1]; + else type += source[index + 1]; + index += 2; + continue; + } + if (char === ')') break; + if (char === '\n') return null; + if (char === ':' && !separated) { + separated = true; + index += 1; + continue; + } + if (separated) id += char; + else type += char; + index += 1; + } + if (source[index] !== ')') return null; + if (!separated || !label || !type || !id) return null; + + return { attrs: { id, label, type, trigger }, next: index + 1 }; +} + /** * Drops whitespace at the document edges, including the space auto-inserted * after a chip, while leaving mentions alone, then re-bases the offsets. A diff --git a/packages/raystack/components/editor/core/nodes.ts b/packages/raystack/components/editor/core/nodes.ts new file mode 100644 index 000000000..b23dd739f --- /dev/null +++ b/packages/raystack/components/editor/core/nodes.ts @@ -0,0 +1,75 @@ +import type { NodeSpec } from 'prosemirror-model'; +import { type MentionAttrs, mentionText } from './mention'; + +/** + * The node specs that the Editor schema and the PromptInput composer schema + * share. This module has no top-level calls, so a PromptInput-only app does + * not keep the rest of the Editor schema. + */ + +export const mentionNodeSpec: NodeSpec = { + inline: true, + group: 'inline', + // Atomic: the cursor never enters it, so it deletes and moves as one unit. + atom: true, + selectable: true, + // ProseMirror makes inline atoms draggable by default, which would let a + // chip be dropped into the middle of a word. + draggable: false, + attrs: { + id: {}, + label: {}, + type: { default: 'mention' }, + trigger: { default: '@' } + }, + leafText: node => mentionText(node.attrs as MentionAttrs), + parseDOM: [ + { + tag: 'span[data-mention-id]', + getAttrs: dom => { + const el = dom as HTMLElement; + return { + id: el.getAttribute('data-mention-id') ?? '', + label: el.getAttribute('data-mention-label') ?? el.textContent, + type: el.getAttribute('data-mention-type') ?? 'mention', + trigger: el.getAttribute('data-mention-trigger') ?? '@' + }; + } + } + ], + // Used for the clipboard's `text/html` flavour and for `editorToHTML`. On + // screen the node view owns the element. Pasting this back restores the chip + // with its id. + toDOM: node => { + const attrs = node.attrs as MentionAttrs; + return [ + 'span', + { + 'data-mention-id': attrs.id, + 'data-mention-label': attrs.label, + 'data-mention-type': attrs.type, + 'data-mention-trigger': attrs.trigger + }, + mentionText(attrs) + ]; + } +}; + +export const paragraphNodeSpec: NodeSpec = { + content: 'inline*', + group: 'block', + parseDOM: [{ tag: 'p' }], + toDOM: () => ['p', 0] +}; + +export const hardBreakNodeSpec: NodeSpec = { + inline: true, + group: 'inline', + selectable: false, + // A code block holds `\n` where a paragraph holds a hard break, so turning + // one into the other keeps the lines. + linebreakReplacement: true, + leafText: () => '\n', + parseDOM: [{ tag: 'br' }], + toDOM: () => ['br'] +}; diff --git a/packages/raystack/components/editor/core/schema.ts b/packages/raystack/components/editor/core/schema.ts new file mode 100644 index 000000000..ab51629dc --- /dev/null +++ b/packages/raystack/components/editor/core/schema.ts @@ -0,0 +1,312 @@ +import { + type MarkSpec, + type NodeSpec, + Schema, + type TagParseRule +} from 'prosemirror-model'; +import { isSafeHref, stripControlCharacters } from './link'; +import { hardBreakNodeSpec, mentionNodeSpec, paragraphNodeSpec } from './nodes'; + +/** A mark that a toolbar button can toggle. */ +export type EditorMark = 'bold' | 'italic' | 'underline' | 'strike' | 'code'; + +export type EditorList = 'bulletList' | 'orderedList' | 'taskList'; + +export type EditorHeadingLevel = 1 | 2 | 3 | 4; + +/** A node or mark that `formats` can allow. */ +export type EditorFormat = + | EditorMark + | EditorList + | 'link' + | 'heading' + | 'blockquote' + | 'codeBlock' + | 'horizontalRule' + | 'mention'; + +export const EDITOR_FORMATS: readonly EditorFormat[] = [ + 'bold', + 'italic', + 'underline', + 'strike', + 'code', + 'link', + 'heading', + 'blockquote', + 'codeBlock', + 'bulletList', + 'orderedList', + 'taskList', + 'horizontalRule', + 'mention' +]; + +export const HEADING_LEVELS: readonly EditorHeadingLevel[] = [1, 2, 3, 4]; + +export const LIST_TYPES: readonly EditorList[] = [ + 'bulletList', + 'orderedList', + 'taskList' +]; + +function readLanguage(element: HTMLElement): string | null { + const code = element.querySelector('code') ?? element; + const match = code.className.match(/(?:^|\s)language-([\w-]+)/); + return match ? match[1] : element.getAttribute('data-language'); +} + +const headingNodeSpec: NodeSpec = { + attrs: { level: { default: 1 } }, + content: 'inline*', + group: 'block', + defining: true, + parseDOM: [1, 2, 3, 4, 5, 6].map( + (level): TagParseRule => ({ + tag: `h${level}`, + attrs: { level: Math.min(level, 4) } + }) + ), + toDOM: node => [`h${node.attrs.level}`, 0] +}; + +const blockquoteNodeSpec: NodeSpec = { + content: 'block+', + group: 'block', + defining: true, + parseDOM: [{ tag: 'blockquote' }], + toDOM: () => ['blockquote', 0] +}; + +const codeBlockNodeSpec: NodeSpec = { + attrs: { language: { default: null } }, + content: 'text*', + marks: '', + group: 'block', + code: true, + defining: true, + parseDOM: [ + { + tag: 'pre', + preserveWhitespace: 'full', + getAttrs: dom => ({ language: readLanguage(dom as HTMLElement) }) + } + ], + toDOM: node => [ + 'pre', + [ + 'code', + { class: node.attrs.language ? `language-${node.attrs.language}` : null }, + 0 + ] + ] +}; + +const bulletListNodeSpec: NodeSpec = { + content: 'listItem+', + group: 'block', + parseDOM: [{ tag: 'ul' }], + toDOM: () => ['ul', 0] +}; + +const orderedListNodeSpec: NodeSpec = { + attrs: { start: { default: 1 } }, + content: 'listItem+', + group: 'block', + parseDOM: [ + { + tag: 'ol', + getAttrs: dom => { + const start = (dom as HTMLElement).getAttribute('start'); + return { start: start ? Number(start) || 1 : 1 }; + } + } + ], + toDOM: node => + node.attrs.start === 1 + ? ['ol', 0] + : ['ol', { start: String(node.attrs.start) }, 0] +}; + +const listItemNodeSpec: NodeSpec = { + content: 'paragraph block*', + defining: true, + parseDOM: [{ tag: 'li' }], + toDOM: () => ['li', 0] +}; + +// Tiptap's markup, so task lists copied from Tiptap or novel paste as tasks. +const taskListNodeSpec: NodeSpec = { + content: 'taskItem+', + group: 'block', + parseDOM: [{ tag: 'ul[data-type="taskList"]', priority: 51 }], + toDOM: () => ['ul', { 'data-type': 'taskList' }, 0] +}; + +const taskItemNodeSpec: NodeSpec = { + attrs: { checked: { default: false } }, + content: 'paragraph block*', + defining: true, + parseDOM: [ + { + tag: 'li[data-type="taskItem"]', + priority: 51, + getAttrs: dom => ({ + checked: (dom as HTMLElement).getAttribute('data-checked') === 'true' + }) + } + ], + toDOM: node => [ + 'li', + { + 'data-type': 'taskItem', + 'data-checked': node.attrs.checked ? 'true' : 'false' + }, + [ + 'label', + [ + 'input', + { type: 'checkbox', checked: node.attrs.checked ? 'checked' : null } + ] + ], + ['div', 0] + ] +}; + +const horizontalRuleNodeSpec: NodeSpec = { + group: 'block', + parseDOM: [{ tag: 'hr' }], + toDOM: () => ['hr'] +}; + +const linkMarkSpec: MarkSpec = { + attrs: { href: {} }, + inclusive: false, + parseDOM: [ + { + tag: 'a[href]', + getAttrs: dom => { + const href = (dom as HTMLElement).getAttribute('href'); + return href && isSafeHref(href) ? { href } : false; + } + } + ], + toDOM: mark => [ + 'a', + { + href: isSafeHref(mark.attrs.href) + ? stripControlCharacters(mark.attrs.href) + : null, + rel: 'noopener noreferrer nofollow' + }, + 0 + ] +}; + +const boldMarkSpec: MarkSpec = { + parseDOM: [ + { tag: 'strong' }, + // Google Docs wraps a whole paste in ``. + { + tag: 'b', + getAttrs: dom => + (dom as HTMLElement).style.fontWeight !== 'normal' && null + }, + { style: 'font-weight=400', clearMark: mark => mark.type.name === 'bold' }, + { + style: 'font-weight', + getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null + } + ], + toDOM: () => ['strong', 0] +}; + +const italicMarkSpec: MarkSpec = { + parseDOM: [ + { tag: 'i' }, + { tag: 'em' }, + { style: 'font-style=italic' }, + { + style: 'font-style=normal', + clearMark: mark => mark.type.name === 'italic' + } + ], + toDOM: () => ['em', 0] +}; + +const underlineMarkSpec: MarkSpec = { + parseDOM: [ + { tag: 'u' }, + { style: 'text-decoration=underline' }, + { style: 'text-decoration-line=underline' } + ], + toDOM: () => ['u', 0] +}; + +const strikeMarkSpec: MarkSpec = { + parseDOM: [ + { tag: 's' }, + { tag: 'del' }, + { tag: 'strike' }, + { style: 'text-decoration=line-through' }, + { style: 'text-decoration-line=line-through' } + ], + toDOM: () => ['s', 0] +}; + +const codeMarkSpec: MarkSpec = { + code: true, + parseDOM: [{ tag: 'code' }], + toDOM: () => ['code', 0] +}; + +const schemaCache = new Map(); + +/** + * Builds the schema for a `formats` allowlist. Paragraphs, text and hard + * breaks are always present. Schemas are cached by their format set, so every + * editor with the same formats shares one schema. + */ +export function buildSchema( + formats: readonly EditorFormat[] = EDITOR_FORMATS +): Schema { + const allowed = new Set(formats); + const key = EDITOR_FORMATS.filter(format => allowed.has(format)).join(','); + const cached = schemaCache.get(key); + if (cached) return cached; + + const has = (format: EditorFormat) => allowed.has(format); + // Order matters: the first `block` node is the default block, and marks + // earlier in the list render outside later ones. + const nodes: Record = { + doc: { content: 'block+' }, + paragraph: paragraphNodeSpec, + text: { group: 'inline' } + }; + if (has('heading')) nodes.heading = headingNodeSpec; + if (has('blockquote')) nodes.blockquote = blockquoteNodeSpec; + if (has('codeBlock')) nodes.codeBlock = codeBlockNodeSpec; + if (has('bulletList')) nodes.bulletList = bulletListNodeSpec; + if (has('orderedList')) nodes.orderedList = orderedListNodeSpec; + if (has('bulletList') || has('orderedList')) + nodes.listItem = listItemNodeSpec; + if (has('taskList')) { + nodes.taskList = taskListNodeSpec; + nodes.taskItem = taskItemNodeSpec; + } + if (has('horizontalRule')) nodes.horizontalRule = horizontalRuleNodeSpec; + nodes.hardBreak = hardBreakNodeSpec; + if (has('mention')) nodes.mention = mentionNodeSpec; + + const marks: Record = {}; + if (has('link')) marks.link = linkMarkSpec; + if (has('bold')) marks.bold = boldMarkSpec; + if (has('italic')) marks.italic = italicMarkSpec; + if (has('underline')) marks.underline = underlineMarkSpec; + if (has('strike')) marks.strike = strikeMarkSpec; + if (has('code')) marks.code = codeMarkSpec; + + const schema = new Schema({ nodes, marks }); + schemaCache.set(key, schema); + return schema; +} diff --git a/packages/raystack/components/editor/core/serializers.ts b/packages/raystack/components/editor/core/serializers.ts new file mode 100644 index 000000000..646e23041 --- /dev/null +++ b/packages/raystack/components/editor/core/serializers.ts @@ -0,0 +1,226 @@ +import type { DOMOutputSpec, Mark, Node as PMNode } from 'prosemirror-model'; +import { escapeAttribute, escapeText } from './html'; +import { docFromJSON, type EditorJSON } from './json'; +import { type EditorMention, type MentionAttrs, mentionText } from './mention'; +import { buildSchema } from './schema'; + +const VOID_ELEMENTS = new Set(['br', 'hr', 'input']); + +function isAttrs(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + !('nodeType' in value) + ); +} + +interface RenderedSpec { + /** The HTML up to the content hole, or all of it when there is no hole. */ + before: string; + /** The HTML after the content hole. */ + after: string; + hole: boolean; +} + +/** Writes a `toDOM` spec as HTML, split at its content hole. */ +function renderSpec(spec: DOMOutputSpec): RenderedSpec { + if (typeof spec === 'string') { + return { before: escapeText(spec), after: '', hole: false }; + } + if (!Array.isArray(spec)) return { before: '', after: '', hole: false }; + + const [tag, ...rest] = spec as readonly [string, ...unknown[]]; + let children = rest; + let attrs = ''; + if (isAttrs(rest[0])) { + children = rest.slice(1); + for (const [name, value] of Object.entries(rest[0])) { + if (value == null) continue; + attrs += ` ${name}="${escapeAttribute(String(value))}"`; + } + } + + let before = `<${tag}${attrs}>`; + if (VOID_ELEMENTS.has(tag)) return { before, after: '', hole: false }; + + let after = ''; + let hole = false; + for (const child of children) { + if (child === 0) { + hole = true; + continue; + } + // The hole can sit in a nested element, as in `['pre', ['code', 0]]`. + const part = renderSpec(child as DOMOutputSpec); + if (hole) { + after += part.before + part.after; + } else if (part.hole) { + before += part.before; + after += part.after; + hole = true; + } else { + before += part.before + part.after; + } + } + return { before, after: `${after}`, hole }; +} + +function markTags(mark: Mark): [string, string] { + const toDOM = mark.type.spec.toDOM; + if (!toDOM) return ['', '']; + const { before, after } = renderSpec(toDOM(mark, true)); + return [before, after]; +} + +function serializeChildren(node: PMNode): string { + if (!node.inlineContent) { + let html = ''; + node.forEach(child => { + html += serializeNode(child); + }); + return html; + } + + // Adjacent text with the same marks shares one element, as + // ProseMirror's DOMSerializer does. + let html = ''; + const open: Mark[] = []; + const closers: string[] = []; + node.forEach(child => { + let keep = 0; + while ( + keep < open.length && + keep < child.marks.length && + child.marks[keep].eq(open[keep]) + ) { + keep += 1; + } + while (open.length > keep) { + open.pop(); + html += closers.pop(); + } + for (let index = keep; index < child.marks.length; index += 1) { + const [start, end] = markTags(child.marks[index]); + html += start; + open.push(child.marks[index]); + closers.push(end); + } + html += serializeNode(child); + }); + while (closers.length) html += closers.pop(); + return html; +} + +function serializeNode(node: PMNode): string { + if (node.isText) return escapeText(node.text ?? ''); + const toDOM = node.type.spec.toDOM; + if (!toDOM) return serializeChildren(node); + const { before, after, hole } = renderSpec(toDOM(node)); + return hole ? before + serializeChildren(node) + after : before + after; +} + +/** HTML for a document, built from the schema's `toDOM` specs with no DOM. */ +export function docToHTML(doc: PMNode): string { + return serializeChildren(doc); +} + +export interface DocText { + text: string; + mentions: EditorMention[]; +} + +/** + * Plain text for a document: text blocks are separated by a blank line, a + * hard break is a newline, and a mention reads as its trigger and label. + * Mention offsets index into `text`. + */ +export function docToText(doc: PMNode): DocText { + let text = ''; + let first = true; + const mentions: EditorMention[] = []; + + doc.descendants(node => { + if (node.isTextblock) { + if (first) first = false; + else text += '\n\n'; + return true; + } + if (node.isText) { + text += node.text ?? ''; + return false; + } + if (node.type.name === 'mention') { + const attrs = node.attrs as MentionAttrs; + const label = mentionText(attrs); + mentions.push({ + ...attrs, + start: text.length, + end: text.length + label.length + }); + text += label; + return false; + } + if (node.type.name === 'hardBreak') { + text += '\n'; + return false; + } + return true; + }); + + return { text, mentions }; +} + +const mentionCache = new WeakMap(); + +/** The mentions in a document, in document order. Cached per document. */ +export function docMentions(doc: PMNode): MentionAttrs[] { + const cached = mentionCache.get(doc); + if (cached) return cached; + const mentions: MentionAttrs[] = []; + doc.descendants(node => { + if (node.type.name === 'mention') { + mentions.push(node.attrs as MentionAttrs); + } + }); + mentionCache.set(doc, mentions); + return mentions; +} + +/** + * The predicate behind `data-empty` and `details.empty`: one paragraph that + * holds only whitespace and hard breaks. + */ +export function isEditorEmpty(doc: PMNode): boolean { + if (doc.childCount !== 1) return false; + const first = doc.firstChild; + if (!first || first.type.name !== 'paragraph') return false; + let empty = true; + first.forEach(child => { + if (child.isText && (child.text ?? '').trim() === '') return; + if (child.type.name === 'hardBreak') return; + empty = false; + }); + return empty; +} + +/** The placeholder shows only while the doc is one paragraph with no content. */ +export function showsPlaceholder(doc: PMNode): boolean { + const first = doc.firstChild; + return ( + doc.childCount === 1 && + first !== null && + first.type.name === 'paragraph' && + first.content.size === 0 + ); +} + +/** Converts editor JSON to HTML. It needs no DOM, so it runs on the server. */ +export function editorToHTML(value: EditorJSON): string { + return docToHTML(docFromJSON(buildSchema(), value)); +} + +/** Converts editor JSON to plain text. Mentions read as `@label`. */ +export function editorToText(value: EditorJSON): string { + return docToText(docFromJSON(buildSchema(), value)).text; +} diff --git a/packages/raystack/components/editor/core/shortcuts.ts b/packages/raystack/components/editor/core/shortcuts.ts new file mode 100644 index 000000000..2dfd6ee07 --- /dev/null +++ b/packages/raystack/components/editor/core/shortcuts.ts @@ -0,0 +1,110 @@ +/** An editor action that has a keyboard shortcut. */ +export type EditorAction = + | 'bold' + | 'italic' + | 'underline' + | 'strike' + | 'code' + | 'link' + | 'paragraph' + | 'heading1' + | 'heading2' + | 'heading3' + | 'heading4' + | 'bulletList' + | 'orderedList' + | 'taskList' + | 'blockquote' + | 'codeBlock' + | 'undo' + | 'redo' + | 'focusToolbar'; + +/** + * Keys in ProseMirror keymap syntax. The same string builds the keymap and + * renders the key caps, so a label cannot drift from its binding. + */ +export const defaultShortcuts: Readonly> = { + bold: 'Mod-b', + italic: 'Mod-i', + underline: 'Mod-u', + strike: 'Mod-Shift-x', + code: 'Mod-e', + link: 'Mod-k', + paragraph: 'Mod-Alt-0', + heading1: 'Mod-Alt-1', + heading2: 'Mod-Alt-2', + heading3: 'Mod-Alt-3', + heading4: 'Mod-Alt-4', + bulletList: 'Mod-Shift-8', + orderedList: 'Mod-Shift-9', + taskList: 'Mod-Shift-7', + blockquote: 'Alt-Shift-.', + codeBlock: 'Mod-Shift-\\', + undo: 'Mod-z', + redo: 'Mod-Shift-z', + focusToolbar: 'Alt-F10' +}; + +export type EditorShortcuts = Partial>; + +export function resolveShortcuts( + overrides?: EditorShortcuts +): Record { + return { ...defaultShortcuts, ...overrides }; +} + +/** The same test prosemirror-keymap uses, so a label and its binding agree on `Mod`. */ +export function isMac(): boolean { + return ( + typeof navigator !== 'undefined' && + /Mac|iP(hone|[oa]d)/.test(navigator.platform) + ); +} + +const MAC_MODIFIERS: Record = { + mod: '⌘', + cmd: '⌘', + meta: '⌘', + ctrl: '⌃', + control: '⌃', + alt: '⌥', + shift: '⇧' +}; + +const MODIFIERS: Record = { + mod: 'Ctrl', + cmd: 'Meta', + meta: 'Meta', + ctrl: 'Ctrl', + control: 'Ctrl', + alt: 'Alt', + shift: 'Shift' +}; + +const KEY_LABELS: Record = { + ArrowUp: '↑', + ArrowDown: '↓', + ArrowLeft: '←', + ArrowRight: '→', + Escape: 'Esc', + ' ': 'Space', + Space: 'Space' +}; + +/** + * Turns a keymap string into key cap labels, one per `Kbd`. + * `formatShortcut('Mod-Shift-x')` is `['⌘', '⇧', 'X']` on macOS and + * `['Ctrl', 'Shift', 'X']` elsewhere. + */ +export function formatShortcut(key: string, mac = isMac()): string[] { + // A trailing `-` is the minus key, as in prosemirror-keymap. + const parts = key.split(/-(?!$)/); + const last = parts.pop() ?? ''; + const modifiers = mac ? MAC_MODIFIERS : MODIFIERS; + const labels = parts.map(part => modifiers[part.toLowerCase()] ?? part); + labels.push( + KEY_LABELS[last] ?? (last.length === 1 ? last.toUpperCase() : last) + ); + return labels; +} diff --git a/packages/raystack/components/editor/suggestion-menu.tsx b/packages/raystack/components/editor/core/suggestion-menu.tsx similarity index 84% rename from packages/raystack/components/editor/suggestion-menu.tsx rename to packages/raystack/components/editor/core/suggestion-menu.tsx index 78571023d..ac8902da1 100644 --- a/packages/raystack/components/editor/suggestion-menu.tsx +++ b/packages/raystack/components/editor/core/suggestion-menu.tsx @@ -2,14 +2,16 @@ import { cx } from 'class-variance-authority'; import type { CSSProperties, ReactNode } from 'react'; -import { Cell } from '../menu/cell'; -import { Popover } from '../popover'; -import { Skeleton } from '../skeleton'; -import styles from './editor.module.css'; +import { Cell } from '../../menu/cell'; +import { Popover } from '../../popover'; +import { Skeleton } from '../../skeleton'; +import styles from './editor-core.module.css'; export interface SuggestionMenuItem { id: string; label: string; + /** Shown under the label. */ + description?: string; type?: string; icon?: ReactNode; trailing?: ReactNode; @@ -19,9 +21,9 @@ export interface SuggestionMenuItem { } /** Rendered in first-appearance order; the leading group has no label. */ -export interface SuggestionGroup { +export interface SuggestionGroup { label?: string; - items: SuggestionMenuItem[]; + items: Item[]; } /** A zero-width caret rect, re-measured by the positioner as the caret moves. */ @@ -54,6 +56,8 @@ export interface SuggestionMenuProps { /** Width the popup takes, in pixels, matching the composer frame's width. */ width?: number; 'aria-label'?: string; + /** Slot name on the listbox. */ + 'data-slot'?: string; } /** Row ids are derived, so the editor can name the highlighted one. */ @@ -74,7 +78,8 @@ export function SuggestionMenu({ loadingRowCount = 3, emptyMessage = 'No results', width, - 'aria-label': ariaLabel = 'Suggestions' + 'aria-label': ariaLabel = 'Suggestions', + 'data-slot': slot }: SuggestionMenuProps) { const total = groups.reduce((count, group) => count + group.items.length, 0); let cursor = -1; @@ -97,7 +102,7 @@ export function SuggestionMenu({ } as CSSProperties } > -
+
{loading && total === 0 ? Array.from({ length: loadingRowCount }).map((_, index) => (
- {item.label} + {item.description ? ( + + {item.label} + + {item.description} + + + ) : ( + item.label + )} ); })} diff --git a/packages/raystack/components/editor/suggestion-plugin.ts b/packages/raystack/components/editor/core/suggestion-plugin.ts similarity index 81% rename from packages/raystack/components/editor/suggestion-plugin.ts rename to packages/raystack/components/editor/core/suggestion-plugin.ts index ed5d262ee..1486d3a21 100644 --- a/packages/raystack/components/editor/suggestion-plugin.ts +++ b/packages/raystack/components/editor/core/suggestion-plugin.ts @@ -2,12 +2,12 @@ import { type EditorState, Plugin, PluginKey, - TextSelection + TextSelection, + type Transaction } from 'prosemirror-state'; import { Decoration, DecorationSet, type EditorView } from 'prosemirror-view'; -import styles from './editor.module.css'; +import styles from './editor-core.module.css'; import type { MentionAttrs } from './mention'; -import { editorSchema, mentionType } from './schema'; /** The active trigger and the query the user is typing after it. */ export interface SuggestionState { @@ -29,9 +29,21 @@ interface SuggestionPluginState { dismissed: { from: number; query: string } | null; } +/** One character that opens a menu, and the rules for its query. */ +export interface SuggestionTrigger { + char: string; + /** + * The number of spaces the query can hold before the menu closes. With no + * limit, a menu part decides when a spaced query ends. + */ + maxSpaces?: number; + /** Class for the inline decoration on the trigger and its query. */ + className?: string; +} + export interface SuggestionPluginOptions { - /** Trigger characters that are currently registered. */ - getTriggers: () => string[]; + /** Triggers that are currently registered. */ + getTriggers: () => SuggestionTrigger[]; /** Notified whenever the active query changes. */ onStateChange: (state: SuggestionState | null) => void; /** @@ -65,11 +77,14 @@ function isBoundary(char: string): boolean { */ function detect( selection: TextSelection, - triggers: string[] + triggers: SuggestionTrigger[] ): SuggestionState | null { if (!selection.empty || triggers.length === 0) return null; const $from = selection.$from; if (!$from.parent.isTextblock) return null; + // Code is literal: a `/` or `@` inside it is never a trigger. + if ($from.parent.type.spec.code) return null; + if ($from.marks().some(mark => mark.type.spec.code)) return null; const before = $from.parent.textBetween( 0, @@ -83,7 +98,7 @@ function detect( for (let index = before.length - 1; index >= stop; index -= 1) { const char = before[index]; if (char === OBJECT) break; - if (triggers.includes(char)) { + if (triggers.some(trigger => trigger.char === char)) { if (!isBoundary(index === 0 ? '' : before[index - 1])) continue; return { trigger: char, @@ -106,7 +121,8 @@ function detect( function carry( active: SuggestionState, from: number, - state: EditorState + state: EditorState, + triggers: SuggestionTrigger[] ): SuggestionState | null { if (!state.selection.empty) return null; const to = state.selection.from; @@ -124,6 +140,15 @@ function carry( const query = state.doc.textBetween(from + 1, to, OBJECT, OBJECT); if (query.includes(OBJECT)) return null; + const trigger = triggers.find(entry => entry.char === active.trigger); + if (!trigger) return null; + if ( + trigger.maxSpaces !== undefined && + (query.match(/\s/g)?.length ?? 0) > trigger.maxSpaces + ) { + return null; + } + return { trigger: active.trigger, query, from, to }; } @@ -173,11 +198,14 @@ export function suggestionPlugin(options: SuggestionPluginOptions): Plugin { }; } + const triggers = options.getTriggers(); + if (previous.active) { const carried = carry( previous.active, tr.mapping.map(previous.active.from, -1), - next + next, + triggers ); if (carried) return { active: carried, dismissed }; } @@ -186,7 +214,7 @@ export function suggestionPlugin(options: SuggestionPluginOptions): Plugin { const detected = next.selection instanceof TextSelection - ? detect(next.selection, options.getTriggers()) + ? detect(next.selection, triggers) : null; if ( @@ -226,9 +254,12 @@ export function suggestionPlugin(options: SuggestionPluginOptions): Plugin { decorations(state) { const active = suggestionPluginKey.getState(state)?.active; if (!active) return null; + const trigger = options + .getTriggers() + .find(entry => entry.char === active.trigger); return DecorationSet.create(state.doc, [ Decoration.inline(active.from, active.to, { - class: styles.activeSuggestion + class: trigger?.className ?? styles.activeSuggestion }) ]); } @@ -275,22 +306,34 @@ export function insertMention( attrs: MentionAttrs, range?: { from: number; to: number } ): void { - const { state } = view; + const tr = mentionTransaction(view.state, attrs, range); + if (tr) view.dispatch(tr); +} + +/** The transaction behind `insertMention`, or null when the schema has no mentions. */ +export function mentionTransaction( + state: EditorState, + attrs: MentionAttrs, + range?: { from: number; to: number } +): Transaction | null { const target = range ?? { from: state.selection.from, to: state.selection.to }; + const mentionType = state.schema.nodes.mention; + if (!mentionType) return null; + const tr = state.tr; const nodes = [mentionType.create(attrs)]; // Skip the trailing space when the caret already sits in front of one, so // picking a mention mid-sentence does not leave a gap. const spaced = isWhitespaceAt(state, target.to); - if (!spaced) nodes.push(editorSchema.text(' ')); + if (!spaced) nodes.push(state.schema.text(' ')); tr.replaceWith(target.from, target.to, nodes); const caret = Math.min(target.from + (spaced ? 1 : 2), tr.doc.content.size); tr.setSelection(TextSelection.create(tr.doc, caret)); tr.scrollIntoView(); - view.dispatch(tr); + return tr; } diff --git a/packages/raystack/components/editor/core/task-item-view.ts b/packages/raystack/components/editor/core/task-item-view.ts new file mode 100644 index 000000000..29054514a --- /dev/null +++ b/packages/raystack/components/editor/core/task-item-view.ts @@ -0,0 +1,96 @@ +import type { Node as PMNode } from 'prosemirror-model'; +import type { + EditorView, + NodeView, + ViewMutationRecord +} from 'prosemirror-view'; + +/** A task item with a real checkbox that toggles `checked`. */ +export class TaskItemView implements NodeView { + readonly dom: HTMLLIElement; + readonly contentDOM: HTMLElement; + + private node: PMNode; + private readonly checkbox: HTMLInputElement; + + constructor( + node: PMNode, + private readonly view: EditorView, + private readonly getPos: () => number | undefined, + className: { item: string; checkbox: string; content: string }, + private readonly onDestroy?: () => void + ) { + this.node = node; + + const dom = document.createElement('li'); + dom.className = className.item; + dom.setAttribute('data-type', 'taskItem'); + + const label = document.createElement('label'); + label.contentEditable = 'false'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = className.checkbox; + checkbox.setAttribute('aria-label', 'Done'); + // Keeps the caret where it is. The click still toggles the box. + checkbox.addEventListener('mousedown', event => event.preventDefault()); + checkbox.addEventListener('change', () => this.toggle()); + label.append(checkbox); + + const content = document.createElement('div'); + content.className = className.content; + + dom.append(label, content); + this.dom = dom; + this.contentDOM = content; + this.checkbox = checkbox; + this.write(node); + this.setEditable(view.editable); + } + + /** A checkbox in a read-only or disabled editor cannot be toggled. */ + setEditable(editable: boolean) { + this.checkbox.disabled = !editable; + } + + private write(node: PMNode) { + const checked = node.attrs.checked === true; + this.checkbox.checked = checked; + this.dom.setAttribute('data-checked', checked ? 'true' : 'false'); + } + + private toggle() { + const pos = this.getPos(); + if (!this.view.editable || pos === undefined) { + this.write(this.node); + return; + } + this.view.dispatch( + this.view.state.tr.setNodeMarkup(pos, undefined, { + ...this.node.attrs, + checked: this.checkbox.checked + }) + ); + } + + update(node: PMNode) { + if (node.type !== this.node.type) return false; + this.node = node; + this.write(node); + return true; + } + + stopEvent(event: Event) { + return event.target === this.checkbox; + } + + ignoreMutation(mutation: ViewMutationRecord) { + if (mutation.type === 'selection') return false; + return !this.contentDOM.contains(mutation.target); + } + + destroy() { + this.onDestroy?.(); + } +} diff --git a/packages/raystack/components/editor/core/use-suggestion-menu.ts b/packages/raystack/components/editor/core/use-suggestion-menu.ts new file mode 100644 index 000000000..c3d28db3a --- /dev/null +++ b/packages/raystack/components/editor/core/use-suggestion-menu.ts @@ -0,0 +1,491 @@ +'use client'; + +import { + type Accessor, + compareItems, + rankItem +} from '@tanstack/match-sorter-utils'; +import type { EditorView } from 'prosemirror-view'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore +} from 'react'; +import { type MentionAttrs, mentionKey } from './mention'; +import type { + EditorMentionItem, + EditorMentionRef, + MentionRegistry, + MentionsData +} from './mention-registry'; +import { + type SuggestionAnchor, + type SuggestionGroup, + suggestionOptionId +} from './suggestion-menu'; +import type { SuggestionState } from './suggestion-plugin'; + +/** Long enough that a fast typist makes one request per word, not per letter. */ +const SEARCH_DEBOUNCE_MS = 150; + +/** The fields the menu reads from a row. */ +export interface SuggestionItem { + id: string; + label: string; + type?: string; + group?: string; + disabled?: boolean; +} + +/** + * Re-renders whatever reads the registry when a config or an item lands. + * `useSyncExternalStore` rather than a subscribe-and-bump effect, because + * `Mentions` registers its trigger from an effect that runs *before* a later + * sibling `Editor` gets to subscribe, and the store re-reads its snapshot after + * subscribing, so that first registration is never missed. + */ +export function useMentionRegistryVersion(registry: MentionRegistry): void { + useSyncExternalStore( + registry.subscribe, + registry.getRevision, + registry.getRevision + ); +} + +/** + * Groups render in first-appearance order of `group` within the results, so + * consumers control section order by ordering their data. Ungrouped items lead + * in a headerless section, and a group that filters down to nothing disappears. + */ +export function toGroups( + items: Item[] +): SuggestionGroup[] { + const ungrouped: Item[] = []; + const order: string[] = []; + const buckets = new Map(); + + for (const item of items) { + if (!item.group) { + ungrouped.push(item); + continue; + } + const bucket = buckets.get(item.group); + if (bucket) { + bucket.push(item); + } else { + buckets.set(item.group, [item]); + order.push(item.group); + } + } + + const groups: SuggestionGroup[] = []; + if (ungrouped.length) groups.push({ items: ungrouped }); + for (const label of order) { + groups.push({ label, items: buckets.get(label) ?? [] }); + } + return groups; +} + +/** Sync data filtering: match-sorter on the accessors, best matches first. */ +export function filterItems( + items: Item[], + query: string, + accessors: ReadonlyArray> +): Item[] { + if (!query) return items; + const ranked = items + .map(item => ({ + item, + ranking: rankItem(item, query, { accessors }) + })) + .filter(entry => entry.ranking.passed); + ranked.sort((a, b) => compareItems(a.ranking, b.ranking)); + return ranked.map(entry => entry.item); +} + +const byLabel = (item: SuggestionItem) => item.label; + +function filterByLabel( + items: Item[], + query: string +): Item[] { + return filterItems(items, query, [byLabel]); +} + +function firstEnabled(items: SuggestionItem[]): number { + return items.findIndex(item => !item.disabled); +} + +function step( + items: SuggestionItem[], + from: number, + direction: 1 | -1 +): number { + if (items.length === 0) return -1; + let index = from; + for (let attempt = 0; attempt < items.length; attempt += 1) { + index = (index + direction + items.length) % items.length; + if (!items[index]?.disabled) return index; + } + return -1; +} + +export interface UseSuggestionMenuOptions { + getView: () => EditorView | null; + /** The active query, or null. */ + suggestion: SuggestionState | null; + /** Whether this menu owns the active query and may show. */ + open: boolean; + /** Sync data, filtered with `filter`. */ + items?: Item[]; + /** Async data. Wins over `items`. */ + onSearch?: ( + query: string, + context: { trigger: string; signal: AbortSignal } + ) => Promise; + /** Filters `items` for a query. Matches on the label by default. */ + filter?: (items: Item[], query: string) => Item[]; + onSelect: (item: Item, state: SuggestionState) => void; + /** Closes the menu and leaves the typed text literal. */ + dismiss: () => void; + onOpenChange?: (open: boolean) => void; + listboxId: string; + /** The part name in the dev warning for a rejected `onSearch`. */ + name: string; +} + +export interface UseSuggestionMenuResult { + open: boolean; + anchor: SuggestionAnchor; + groups: SuggestionGroup[]; + /** The rows in display order. `highlightedIndex` indexes into it. */ + flat: Item[]; + highlightedIndex: number; + setHighlightedIndex: (index: number) => void; + loading: boolean; + select: (item: Item) => void; + close: () => void; + activeOptionId: string | undefined; + /** Routed from the ProseMirror plugin while a query is active. */ + handleKeyDown: (event: KeyboardEvent, state: SuggestionState) => boolean; +} + +export function useSuggestionMenu({ + getView, + suggestion, + open, + items: syncItems, + onSearch: search, + filter = filterByLabel, + onSelect, + dismiss, + onOpenChange, + listboxId, + name +}: UseSuggestionMenuOptions): UseSuggestionMenuResult { + const suggestionRef = useRef(suggestion); + suggestionRef.current = suggestion; + const getViewRef = useRef(getView); + getViewRef.current = getView; + const filterRef = useRef(filter); + filterRef.current = filter; + const onSelectRef = useRef(onSelect); + onSelectRef.current = onSelect; + const dismissRef = useRef(dismiss); + dismissRef.current = dismiss; + + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + + const trigger = suggestion?.trigger; + const query = suggestion?.query ?? ''; + + // Async results: debounced, aborted on supersede, and guarded by a sequence + // number so a slow response for an old query can never land. + const sequenceRef = useRef(0); + const warnedRef = useRef(false); + + useEffect(() => { + if (!open || trigger === undefined) { + setResults(current => (current.length === 0 ? current : [])); + setLoading(false); + return; + } + + if (!search) { + setLoading(false); + setResults(filterRef.current(syncItems ?? [], query)); + return; + } + + const sequence = (sequenceRef.current += 1); + const controller = new AbortController(); + setLoading(true); + + const timer = window.setTimeout(() => { + search(query, { trigger, signal: controller.signal }) + .then(items => { + if (sequence !== sequenceRef.current) return; + setResults(items); + setLoading(false); + }) + .catch((error: unknown) => { + if (sequence !== sequenceRef.current) return; + setLoading(false); + setResults([]); + const aborted = + controller.signal.aborted || + (error instanceof Error && error.name === 'AbortError'); + if ( + !aborted && + !warnedRef.current && + process.env.NODE_ENV !== 'production' + ) { + warnedRef.current = true; + console.warn( + `[Apsara] ${name} onSearch rejected; the menu falls ` + + 'back to its empty state.', + error + ); + } + }); + }, SEARCH_DEBOUNCE_MS); + + return () => { + window.clearTimeout(timer); + controller.abort(); + }; + }, [open, trigger, query, search, syncItems, name]); + + const groups = useMemo(() => toGroups(results), [results]); + const flat = useMemo(() => groups.flatMap(group => group.items), [groups]); + const flatRef = useRef(flat); + flatRef.current = flat; + + // The first enabled row is auto-highlighted whenever the result set changes. + // Keyed on what the rows *are* rather than on the array's identity: an inline + // `items={[…]}` prop is a fresh array on every consumer render, and resetting + // on identity would throw away the user's arrow-key position whenever + // something unrelated re-rendered above the composer. + const rowSignature = useMemo( + () => + flat + .map(item => `${item.type ?? ''}:${item.id}:${item.disabled ? 1 : 0}`) + .join('\0'), + [flat] + ); + // biome-ignore lint/correctness/useExhaustiveDependencies: the signature is the trigger; the rows are read through a ref + useEffect(() => { + setHighlightedIndex(firstEnabled(flatRef.current)); + }, [rowSignature]); + + // Held past the config that supplied it: closing clears the active trigger, + // so reading the callback off the live config would swallow the `false`. + const onOpenChangeRef = useRef<((open: boolean) => void) | undefined>( + undefined + ); + if (onOpenChange) onOpenChangeRef.current = onOpenChange; + const lastOpenRef = useRef(false); + useEffect(() => { + if (lastOpenRef.current === open) return; + lastOpenRef.current = open; + onOpenChangeRef.current?.(open); + }, [open]); + + const close = useCallback(() => { + dismissRef.current(); + }, []); + + // Spaces are allowed while results are non-empty, so multi-word entities stay + // filterable. The first keystroke that empties the results while the query + // already contains a space gives up and leaves the text literal. + useEffect(() => { + if (!open || loading) return; + if (results.length > 0) return; + if (!query.includes(' ')) return; + close(); + }, [open, loading, results.length, query, close]); + + const select = useCallback((item: Item) => { + const state = suggestionRef.current; + if (!state) return; + onSelectRef.current(item, state); + }, []); + + // The last rect the caret actually had. Selecting an item takes the query + // range out of the document in the same breath as it closes the menu, but the + // popup is still animating out and the positioner keeps measuring. Without + // something to hand back, it would read a zero rect and the closing menu + // would jump to the top-left corner of the viewport and flicker there. + const lastRectRef = useRef(null); + + const anchor = useMemo( + () => ({ + get contextElement() { + return getViewRef.current()?.dom; + }, + getBoundingClientRect: () => { + const view = getViewRef.current(); + const state = suggestionRef.current; + if (view && state) { + try { + const coords = view.coordsAtPos(state.from); + const rect = new DOMRect( + coords.left, + coords.top, + 0, + Math.max(0, coords.bottom - coords.top) + ); + lastRectRef.current = rect; + return rect; + } catch { + // The range is gone, or the browser is mid-relayout. + } + } + // jsdom has no geometry to give either, and no animation to cover. + return lastRectRef.current ?? new DOMRect(0, 0, 0, 0); + } + }), + [] + ); + + const highlightedRef = useRef(highlightedIndex); + highlightedRef.current = highlightedIndex; + const openRef = useRef(open); + openRef.current = open; + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!openRef.current) return false; + + const items = flatRef.current; + const current = highlightedRef.current; + + const consume = () => { + event.preventDefault(); + // Enter must not reach the form and Escape must not reach ChatPanel, + // Dialog or Drawer, either of which would destroy the draft. + event.stopPropagation(); + }; + + switch (event.key) { + case 'ArrowDown': + consume(); + setHighlightedIndex(step(items, current, 1)); + return true; + case 'ArrowUp': + consume(); + setHighlightedIndex(step(items, current, -1)); + return true; + case 'Escape': + consume(); + close(); + return true; + case 'Enter': + case 'Tab': { + const item = items[current]; + if (!item || item.disabled) { + // Nothing to pick: leave the text literal and let the key through + // to submit or to move focus. + close(); + return false; + } + consume(); + select(item); + return true; + } + default: + return false; + } + }, + [close, select] + ); + + return { + open, + anchor, + groups, + flat, + highlightedIndex, + setHighlightedIndex, + loading, + select, + close, + activeOptionId: + open && highlightedIndex >= 0 + ? suggestionOptionId(listboxId, highlightedIndex) + : undefined, + handleKeyDown + }; +} + +/** + * `icon`, `trailing` and `data` cannot survive serialization, so a chip parsed + * from `defaultValue` starts label-only and fills in when the consumer's + * `resolveMentions` resolves, the same progressive enhancement `Select.Value` + * uses when it falls back to the raw value until an item registers. A rejection + * or a missing item leaves the chip label-only; it is never an error state and + * the chip is never removed. + */ +export function useMentionResolution( + registry: MentionRegistry, + mentions: MentionAttrs[], + refreshLabels: (labels: Map) => void, + getResolver: (trigger: string) => MentionsData['resolveMentions'] = trigger => + registry.get(trigger)?.resolveMentions +): void { + const requestedRef = useRef(new Set()); + const refreshRef = useRef(refreshLabels); + refreshRef.current = refreshLabels; + const getResolverRef = useRef(getResolver); + getResolverRef.current = getResolver; + + useEffect(() => { + if (mentions.length === 0) return; + + const byTrigger = new Map(); + + for (const mention of mentions) { + const key = mentionKey(mention.trigger, mention.type, mention.id); + if (requestedRef.current.has(key)) continue; + if (registry.has(mention.trigger, mention.type, mention.id)) continue; + if (!getResolverRef.current(mention.trigger)) continue; + requestedRef.current.add(key); + const bucket = byTrigger.get(mention.trigger); + const ref = { + type: mention.type, + id: mention.id, + label: mention.label + }; + if (bucket) bucket.push(ref); + else byTrigger.set(mention.trigger, [ref]); + } + + if (byTrigger.size === 0) return; + + for (const [trigger, refs] of byTrigger) { + const resolve = getResolverRef.current(trigger); + if (!resolve) continue; + resolve(refs) + .then((items: EditorMentionItem[]) => { + if (items.length === 0) return; + registry.rememberAll(trigger, items); + const labels = new Map(); + for (const item of items) { + labels.set( + mentionKey(trigger, item.type ?? 'mention', item.id), + item.label + ); + } + refreshRef.current(labels); + }) + .catch(() => { + // Label-only is the fallback, so there is nothing to recover. + }); + } + }, [registry, mentions]); +} diff --git a/packages/raystack/components/editor/editor-block-button.tsx b/packages/raystack/components/editor/editor-block-button.tsx new file mode 100644 index 000000000..5ff09f37e --- /dev/null +++ b/packages/raystack/components/editor/editor-block-button.tsx @@ -0,0 +1,65 @@ +'use client'; + +import type { Command } from 'prosemirror-state'; +import { + type EditorBlock, + insertHorizontalRule, + isBlockActive, + toggleBlock, + toggleList +} from './core/commands'; +import { useEditorStore, useStoreSelector } from './editor-context'; +import { EditorControl, type EditorControlBaseProps } from './editor-control'; +import { BLOCK_DEFAULTS } from './editor-defaults'; + +export interface EditorBlockButtonProps extends EditorControlBaseProps { + /** The block the button toggles. `horizontalRule` inserts a divider. */ + block: EditorBlock; +} + +function blockCommand(block: EditorBlock): Command { + if (block === 'horizontalRule') return insertHorizontalRule; + if (block === 'blockquote' || block === 'codeBlock') + return toggleBlock(block); + return toggleList(block); +} + +/** Wraps, lifts or sets a block, or inserts a divider. */ +export function EditorBlockButton({ + block, + label, + children, + onClick, + ...props +}: EditorBlockButtonProps) { + const store = useEditorStore('Editor.BlockButton'); + const active = useStoreSelector(store, current => + isBlockActive(current.state, block) + ); + const enabled = useStoreSelector( + store, + current => current.isEditable() && blockCommand(block)(current.state) + ); + + if (!(block in store.schema.nodes)) return null; + const { label: defaultLabel, Icon, action } = BLOCK_DEFAULTS[block]; + + return ( + { + store.run(blockCommand(block)); + onClick?.(event); + }} + {...props} + > + {children ?? } + + ); +} + +EditorBlockButton.displayName = 'Editor.BlockButton'; diff --git a/packages/raystack/components/editor/editor-content.tsx b/packages/raystack/components/editor/editor-content.tsx new file mode 100644 index 000000000..674afa914 --- /dev/null +++ b/packages/raystack/components/editor/editor-content.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { useMergedRefs } from '@base-ui/utils/useMergedRefs'; +import { cx } from 'class-variance-authority'; +import { EditorView } from 'prosemirror-view'; +import { + type ComponentProps, + Fragment, + useLayoutEffect, + useRef, + useState +} from 'react'; +import { createPortal } from 'react-dom'; +import coreStyles from './core/editor-core.module.css'; +import { docToHTML } from './core/serializers'; +import { useMentionRegistryVersion } from './core/use-suggestion-menu'; +import styles from './editor.module.css'; +import { useEditorStore, useStoreSelector } from './editor-context'; + +export interface EditorContentProps + extends Omit< + ComponentProps<'div'>, + 'contentEditable' | 'children' | 'dangerouslySetInnerHTML' | 'role' + > { + /** + * Turns the browser's spell check on or off. + * @default true + */ + spellCheck?: boolean; +} + +/** The mount point for the editable document. */ +export function EditorContent({ + className, + spellCheck = true, + ref, + ...props +}: EditorContentProps) { + const store = useEditorStore('Editor.Content'); + const hostRef = useRef(null); + const mergedRef = useMergedRefs(hostRef, ref); + + // The server and the first client render show the doc as HTML, so there is + // no empty box before ProseMirror takes the subtree over. + const [initialHtml] = useState(() => ({ + __html: docToHTML(store.state.doc) + })); + + // ProseMirror adds its own classes to the element, and React would drop + // them when it writes a new `className`. React writes the first value only, + // and ProseMirror's `attributes` prop applies the later ones. + const classes = cx(coreStyles.editor, styles.content, className); + const [initialClasses] = useState(classes); + const classesRef = useRef(classes); + classesRef.current = classes; + + useLayoutEffect(() => { + const host = hostRef.current; + if (!host) return; + host.replaceChildren(); + const view = new EditorView( + { mount: host }, + { + state: store.state, + editable: () => store.isEditable(), + dispatchTransaction: store.dispatch, + nodeViews: store.nodeViews, + attributes: { class: classesRef.current } + } + ); + store.attachView(view); + if (store.autoFocus) { + store.focus(store.autoFocus === true ? undefined : store.autoFocus); + } + return () => { + store.detachView(view); + view.destroy(); + }; + }, [store]); + + useLayoutEffect(() => { + store.view?.setProps({ attributes: { class: classes } }); + }, [store, classes]); + + const readOnly = useStoreSelector(store, current => current.props.readOnly); + const disabled = useStoreSelector(store, current => current.props.disabled); + const hasMenus = useStoreSelector(store, current => current.hasTriggers()); + const menu = useStoreSelector(store, current => { + const trigger = current.suggestion?.trigger; + return trigger ? current.menus.get(trigger) : undefined; + }); + const portals = useStoreSelector(store, current => current.mentionPortals); + useMentionRegistryVersion(store.mentions); + + return ( + <> +
+ {portals.map(portal => { + const item = store.mentions.lookup( + portal.attrs.trigger, + portal.attrs.type, + portal.attrs.id + ); + return ( + + {item?.icon ? createPortal(item.icon, portal.iconTarget) : null} + {item?.trailing + ? createPortal(item.trailing, portal.trailingTarget) + : null} + + ); + })} + + ); +} + +EditorContent.displayName = 'Editor.Content'; diff --git a/packages/raystack/components/editor/editor-context.ts b/packages/raystack/components/editor/editor-context.ts new file mode 100644 index 000000000..dedca259a --- /dev/null +++ b/packages/raystack/components/editor/editor-context.ts @@ -0,0 +1,62 @@ +'use client'; + +import type { EditorState } from 'prosemirror-state'; +import { createContext, useContext, useRef, useSyncExternalStore } from 'react'; +import type { EditorStore } from './editor-store'; +import type { EditorApi } from './editor-types'; + +export const EditorContext = createContext(null); + +export function useEditorStore(part: string): EditorStore { + const store = useContext(EditorContext); + if (!store) throw new Error(`${part} must be used within `); + return store; +} + +/** + * Reads a value off the store and re-renders only when it changes. A new + * selector each render is fine: `isEqual` keeps the previous value when the + * result is the same. + */ +export function useStoreSelector( + store: EditorStore, + selector: (store: EditorStore) => T, + isEqual: (a: T, b: T) => boolean = Object.is +): T { + const cache = useRef<{ + revision: number; + selector: (store: EditorStore) => T; + value: T; + } | null>(null); + + const getSnapshot = () => { + const cached = cache.current; + if ( + cached && + cached.revision === store.revision && + cached.selector === selector + ) { + return cached.value; + } + const next = selector(store); + const value = cached && isEqual(cached.value, next) ? cached.value : next; + cache.current = { revision: store.revision, selector, value }; + return value; + }; + + return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); +} + +/** The editor's API: commands, dry runs and serializers. */ +export function useEditor(): EditorApi { + return useEditorStore('useEditor').api; +} + +/** Selects a value from the editor state. Re-renders only when it changes. */ +export function useEditorState( + selector: (state: EditorState) => T, + isEqual?: (a: T, b: T) => boolean +): T { + const store = useEditorStore('useEditorState'); + return useStoreSelector(store, current => selector(current.state), isEqual); +} diff --git a/packages/raystack/components/editor/editor-control.tsx b/packages/raystack/components/editor/editor-control.tsx new file mode 100644 index 000000000..86d55224f --- /dev/null +++ b/packages/raystack/components/editor/editor-control.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import { + type ComponentProps, + cloneElement, + type ReactElement, + useMemo +} from 'react'; +import { Kbd } from '../kbd'; +import { Toolbar } from '../toolbar'; +import { Tooltip } from '../tooltip'; +import { formatShortcut } from './core/shortcuts'; +import styles from './editor.module.css'; + +/** The key caps for a shortcut, or nothing when it is turned off. */ +export function EditorShortcutKeys({ + shortcut +}: { + shortcut?: string | false; +}) { + const keys = useMemo( + () => (shortcut ? formatShortcut(shortcut) : []), + [shortcut] + ); + if (keys.length === 0) return null; + return ( + + {keys.map((key, index) => ( + {key} + ))} + + ); +} + +export interface EditorControlBaseProps + extends Omit< + ComponentProps, + 'aria-pressed' | 'render' + > { + /** Accessible name and tooltip text. */ + label?: string; + /** + * Shows the label and shortcut in a tooltip. + * @default true + */ + tooltip?: boolean; +} + +interface EditorControlProps extends EditorControlBaseProps { + label: string; + shortcut?: string | false; + /** Sets `aria-pressed`. Leave undefined for a plain button. */ + pressed?: boolean; + /** A trigger, such as ``, that the button renders inside. */ + render?: ReactElement<{ render?: ReactElement }>; +} + +/** A toolbar button with a tooltip that shows its label and shortcut. */ +export function EditorControl({ + label, + shortcut, + tooltip = true, + pressed, + render, + className, + onMouseDown, + children, + ...props +}: EditorControlProps) { + const button = ( + { + event.preventDefault(); + onMouseDown?.(event); + }} + {...props} + /> + ); + // The tooltip stays mounted when it is off, so the trigger element, and + // any menu anchored to it, is never remounted. + return ( + + + {children} + + + + {label} + + + + + ); +} diff --git a/packages/raystack/components/editor/editor-defaults.ts b/packages/raystack/components/editor/editor-defaults.ts new file mode 100644 index 000000000..064bff4a5 --- /dev/null +++ b/packages/raystack/components/editor/editor-defaults.ts @@ -0,0 +1,78 @@ +import type { ComponentType } from 'react'; +import { + BoldIcon, + ChecklistIcon, + CodeBlockIcon, + CodeIcon, + Heading1Icon, + Heading2Icon, + Heading3Icon, + Heading4Icon, + ItalicIcon, + ListIcon, + MinusIcon, + NumberedListIcon, + QuoteIcon, + StrikethroughIcon, + TextIcon, + UnderlineIcon +} from '~/icons'; +import type { EditorBlock } from './core/commands'; +import type { EditorHeadingLevel, EditorMark } from './core/schema'; +import type { EditorAction } from './core/shortcuts'; + +interface ControlDefault { + label: string; + Icon: ComponentType; + action?: EditorAction; +} + +export const MARK_DEFAULTS: Record = { + bold: { label: 'Bold', Icon: BoldIcon, action: 'bold' }, + italic: { label: 'Italic', Icon: ItalicIcon, action: 'italic' }, + underline: { + label: 'Underline', + Icon: UnderlineIcon, + action: 'underline' + }, + strike: { + label: 'Strikethrough', + Icon: StrikethroughIcon, + action: 'strike' + }, + code: { label: 'Inline code', Icon: CodeIcon, action: 'code' } +}; + +export const BLOCK_DEFAULTS: Record = { + blockquote: { label: 'Quote', Icon: QuoteIcon, action: 'blockquote' }, + codeBlock: { + label: 'Code block', + Icon: CodeBlockIcon, + action: 'codeBlock' + }, + bulletList: { + label: 'Bulleted list', + Icon: ListIcon, + action: 'bulletList' + }, + orderedList: { + label: 'Numbered list', + Icon: NumberedListIcon, + action: 'orderedList' + }, + taskList: { label: 'Checklist', Icon: ChecklistIcon, action: 'taskList' }, + horizontalRule: { label: 'Divider', Icon: MinusIcon } +}; + +export const PARAGRAPH_DEFAULT: ControlDefault = { + label: 'Text', + Icon: TextIcon, + action: 'paragraph' +}; + +export const HEADING_DEFAULTS: Record = { + 1: { label: 'Heading 1', Icon: Heading1Icon, action: 'heading1' }, + 2: { label: 'Heading 2', Icon: Heading2Icon, action: 'heading2' }, + 3: { label: 'Heading 3', Icon: Heading3Icon, action: 'heading3' }, + 4: { label: 'Heading 4', Icon: Heading4Icon, action: 'heading4' } +}; diff --git a/packages/raystack/components/editor/editor-floating-context.ts b/packages/raystack/components/editor/editor-floating-context.ts new file mode 100644 index 000000000..66d0204f0 --- /dev/null +++ b/packages/raystack/components/editor/editor-floating-context.ts @@ -0,0 +1,29 @@ +'use client'; + +import { createContext, useContext, useEffect } from 'react'; + +export interface FloatingToolbarContextValue { + visible: boolean; + /** Swaps the buttons for the link field. */ + openLink: () => void; + /** Keeps the toolbar open while a menu from it is open. Returns the release. */ + hold: () => () => void; +} + +export const FloatingToolbarContext = + createContext(null); + +/** The floating toolbar around a control, or null inside a fixed toolbar. */ +export function useFloatingToolbar(): FloatingToolbarContextValue | null { + return useContext(FloatingToolbarContext); +} + +/** Holds the floating toolbar open while `open` is true. */ +export function useHoldFloatingToolbar(open: boolean): void { + const floating = useFloatingToolbar(); + const hold = floating?.hold; + useEffect(() => { + if (!open || !hold) return; + return hold(); + }, [open, hold]); +} diff --git a/packages/raystack/components/editor/editor-floating-toolbar.tsx b/packages/raystack/components/editor/editor-floating-toolbar.tsx new file mode 100644 index 000000000..16869dd06 --- /dev/null +++ b/packages/raystack/components/editor/editor-floating-toolbar.tsx @@ -0,0 +1,249 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import { + AllSelection, + type EditorState, + TextSelection +} from 'prosemirror-state'; +import { + type ComponentProps, + useCallback, + useEffect, + useMemo, + useRef, + useState +} from 'react'; +import { Popover } from '../popover'; +import { Toolbar } from '../toolbar'; +import { Tooltip } from '../tooltip'; +import styles from './editor.module.css'; +import { useEditorStore, useStoreSelector } from './editor-context'; +import { + FloatingToolbarContext, + type FloatingToolbarContextValue +} from './editor-floating-context'; +import { EditorLinkForm } from './editor-link-form'; +import { focusFirstControl } from './editor-toolbar'; + +export interface EditorFloatingToolbarProps + extends Omit, 'orientation'> { + /** + * The side of the selection the toolbar shows on. + * @default 'top' + */ + side?: 'top' | 'bottom'; + /** + * How the toolbar aligns to the selection. + * @default 'start' + */ + align?: 'start' | 'center' | 'end'; + /** + * Distance from the selection in pixels. + * @default 8 + */ + sideOffset?: number; + /** Decides whether the toolbar shows for a state. Replaces the default rule. */ + shouldShow?: (state: EditorState) => boolean; +} + +/** A non-empty text selection outside a code block. */ +function defaultShouldShow(state: EditorState): boolean { + const { selection } = state; + if ( + !(selection instanceof TextSelection || selection instanceof AllSelection) + ) { + return false; + } + if (selection.empty) return false; + if (selection.$from.parent.type.spec.code) return false; + if (selection.$to.parent.type.spec.code) return false; + return ( + state.doc.textBetween(selection.from, selection.to, ' ', ' ').trim() + .length > 0 + ); +} + +/** A toolbar that shows above a text selection. */ +export function EditorFloatingToolbar({ + side = 'top', + align = 'start', + sideOffset = 8, + shouldShow, + className, + children, + 'aria-label': ariaLabel = 'Formatting', + ...props +}: EditorFloatingToolbarProps) { + const store = useEditorStore('Editor.FloatingToolbar'); + const popupRef = useRef(null); + const [mode, setMode] = useState<'buttons' | 'link'>('buttons'); + const [holds, setHolds] = useState(0); + const [focusWithin, setFocusWithin] = useState(false); + const [dismissedAt, setDismissedAt] = useState(null); + + const selectionKey = useStoreSelector( + store, + current => `${current.state.selection.from}:${current.state.selection.to}` + ); + const readOnly = useStoreSelector(store, current => current.props.readOnly); + const eligible = useStoreSelector( + store, + current => + current.isEditable() && + !current.pointerSelecting && + current.view !== null && + (shouldShow ?? defaultShouldShow)(current.state) + ); + const focused = useStoreSelector(store, current => current.focused); + + // Escape hides the toolbar until the selection changes. + if (dismissedAt !== null && dismissedAt !== selectionKey) { + setDismissedAt(null); + } + + const visible = + eligible && + (focused || focusWithin || holds > 0 || mode === 'link') && + dismissedAt !== selectionKey; + + useEffect(() => { + if (!eligible) setMode('buttons'); + }, [eligible]); + + // A command run from the keyboard can hide the toolbar while it has focus, + // as Code block does. Focus goes back to the text instead of the page. + useEffect(() => { + if (visible || !focusWithin) return; + setFocusWithin(false); + store.view?.focus(); + }, [visible, focusWithin, store]); + + const visibleRef = useRef(visible); + visibleRef.current = visible; + useEffect( + () => + store.registerToolbar( + 1, + () => visibleRef.current && focusFirstControl(popupRef.current) + ), + [store] + ); + + const hold = useCallback(() => { + setHolds(count => count + 1); + return () => setHolds(count => count - 1); + }, []); + + const context = useMemo( + () => ({ + visible, + openLink: () => setMode('link'), + hold + }), + [visible, hold] + ); + + const lastRectRef = useRef(null); + // biome-ignore lint/correctness/useExhaustiveDependencies: a new anchor on every selection change makes the positioner measure again + const anchor = useMemo( + () => ({ + get contextElement() { + return store.view?.dom; + }, + getBoundingClientRect: () => { + const view = store.view; + if (view) { + const { from, to } = view.state.selection; + try { + const start = view.coordsAtPos(from); + const end = view.coordsAtPos(to); + const left = Math.min(start.left, end.left); + const top = Math.min(start.top, end.top); + const rect = new DOMRect( + left, + top, + Math.max(start.right, end.right) - left, + Math.max(start.bottom, end.bottom) - top + ); + lastRectRef.current = rect; + return rect; + } catch { + // The selection is gone, or the browser is mid-relayout. + } + } + return lastRectRef.current ?? new DOMRect(0, 0, 0, 0); + } + }), + [store, selectionKey] + ); + + if (readOnly) return null; + + return ( + + { + if (open) return; + if (details.reason === 'escape-key') { + setDismissedAt(selectionKey); + setMode('buttons'); + store.view?.focus(); + } else if ( + details.reason === 'outside-press' || + details.reason === 'focus-out' + ) { + setMode('buttons'); + } + }} + > + { + // Tab past either end lands on a focus guard beside the popup, + // which unmounts with it, so focus goes back to the text. + if ( + event.target instanceof HTMLElement && + event.target.hasAttribute('data-base-ui-focus-guard') + ) { + setMode('buttons'); + store.view?.focus(); + return; + } + setFocusWithin(true); + }} + onBlur={event => { + if (!event.currentTarget.contains(event.relatedTarget as Node)) { + setFocusWithin(false); + } + }} + > + {mode === 'link' ? ( + setMode('buttons')} /> + ) : ( + + + {children} + + + )} + + + + ); +} + +EditorFloatingToolbar.displayName = 'Editor.FloatingToolbar'; diff --git a/packages/raystack/components/editor/editor-heading-menu.tsx b/packages/raystack/components/editor/editor-heading-menu.tsx new file mode 100644 index 000000000..df95cac21 --- /dev/null +++ b/packages/raystack/components/editor/editor-heading-menu.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { activeTextStyle, setHeading, setParagraph } from './core/commands'; +import { type EditorHeadingLevel, HEADING_LEVELS } from './core/schema'; +import { useEditorStore, useStoreSelector } from './editor-context'; +import type { EditorControlBaseProps } from './editor-control'; +import { HEADING_DEFAULTS, PARAGRAPH_DEFAULT } from './editor-defaults'; +import { + EditorMenuControl, + EditorMenuItems, + type EditorMenuOption, + sameFlags +} from './editor-menu-control'; + +export interface EditorHeadingMenuProps extends EditorControlBaseProps { + /** + * The heading levels in the menu. + * @default [1, 2, 3, 4] + */ + levels?: EditorHeadingLevel[]; +} + +/** A menu that sets the text block to regular text or a heading. */ +export function EditorHeadingMenu({ + levels = HEADING_LEVELS as EditorHeadingLevel[], + label = 'Text style', + ...props +}: EditorHeadingMenuProps) { + const store = useEditorStore('Editor.HeadingMenu'); + const current = useStoreSelector(store, state => + activeTextStyle(state.state) + ); + const editable = useStoreSelector(store, state => state.isEditable()); + + if (!store.schema.nodes.heading) return null; + + const Icon = + typeof current === 'number' + ? HEADING_DEFAULTS[current].Icon + : PARAGRAPH_DEFAULT.Icon; + + return ( + } + disabled={!editable} + {...props} + > + + + ); +} + +EditorHeadingMenu.displayName = 'Editor.HeadingMenu'; + +function HeadingOptions({ levels }: { levels: EditorHeadingLevel[] }) { + const store = useEditorStore('Editor.HeadingMenu'); + const current = useStoreSelector(store, state => + activeTextStyle(state.state) + ); + const can = useStoreSelector( + store, + state => + [setParagraph, ...levels.map(level => setHeading(level))].map(command => + command(state.state) + ), + sameFlags + ); + + const options: EditorMenuOption[] = [ + { + key: 'paragraph', + ...PARAGRAPH_DEFAULT, + shortcut: store.shortcuts.paragraph, + active: current === 'paragraph', + disabled: current !== 'paragraph' && !can[0], + run: () => store.run(setParagraph) + }, + ...levels.map((level, index) => { + const { action, ...rest } = HEADING_DEFAULTS[level]; + return { + key: `heading-${level}`, + ...rest, + shortcut: action && store.shortcuts[action], + active: current === level, + disabled: current !== level && !can[index + 1], + run: () => store.run(setHeading(level)) + }; + }) + ]; + + return ; +} diff --git a/packages/raystack/components/editor/editor-history-button.tsx b/packages/raystack/components/editor/editor-history-button.tsx new file mode 100644 index 000000000..8108b7d18 --- /dev/null +++ b/packages/raystack/components/editor/editor-history-button.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { redo, undo } from 'prosemirror-history'; +import { RedoIcon, UndoIcon } from '~/icons'; +import { useEditorStore, useStoreSelector } from './editor-context'; +import { EditorControl, type EditorControlBaseProps } from './editor-control'; + +export interface EditorHistoryButtonProps extends EditorControlBaseProps { + /** Which way the button steps through history. */ + action: 'undo' | 'redo'; +} + +/** Undoes or redoes the last change. */ +export function EditorHistoryButton({ + action, + label, + children, + onClick, + ...props +}: EditorHistoryButtonProps) { + const store = useEditorStore('Editor.HistoryButton'); + const command = action === 'undo' ? undo : redo; + const enabled = useStoreSelector( + store, + current => current.isEditable() && command(current.state) + ); + const Icon = action === 'undo' ? UndoIcon : RedoIcon; + + return ( + { + store.run(command); + onClick?.(event); + }} + {...props} + > + {children ?? } + + ); +} + +EditorHistoryButton.displayName = 'Editor.HistoryButton'; diff --git a/packages/raystack/components/editor/editor-link-button.tsx b/packages/raystack/components/editor/editor-link-button.tsx new file mode 100644 index 000000000..d0b58e387 --- /dev/null +++ b/packages/raystack/components/editor/editor-link-button.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { LinkIcon } from '~/icons'; +import { Popover } from '../popover'; +import { activeLink, canSetLink } from './core/commands'; +import styles from './editor.module.css'; +import { useEditorStore, useStoreSelector } from './editor-context'; +import { EditorControl, type EditorControlBaseProps } from './editor-control'; +import { useFloatingToolbar } from './editor-floating-context'; +import { EditorLinkForm } from './editor-link-form'; + +export interface EditorLinkButtonProps extends EditorControlBaseProps {} + +/** + * Adds, edits or removes a link. In the floating toolbar the URL field + * replaces the buttons. In the fixed toolbar it opens in a popover. + */ +export function EditorLinkButton({ + label = 'Link', + tooltip = true, + children, + onClick, + ...props +}: EditorLinkButtonProps) { + const store = useEditorStore('Editor.LinkButton'); + const floating = useFloatingToolbar(); + const [open, setOpen] = useState(false); + + const active = useStoreSelector( + store, + current => activeLink(current.state) !== null + ); + const enabled = useStoreSelector( + store, + current => current.isEditable() && canSetLink(current.state) + ); + + const openLink = floating?.openLink; + const visible = floating?.visible ?? false; + useEffect(() => { + if (!enabled) return; + if (openLink) { + return store.registerLinkOpener(1, () => { + if (!visible) return false; + openLink(); + return true; + }); + } + return store.registerLinkOpener(0, () => { + setOpen(true); + return true; + }); + }, [store, enabled, openLink, visible]); + + if (!store.schema.marks.link) return null; + + const control = { + 'data-slot': 'editor-link-button', + label, + shortcut: store.shortcuts.link, + tooltip, + pressed: active, + disabled: !enabled + }; + const content = children ?? ; + + if (floating) { + return ( + { + floating.openLink(); + onClick?.(event); + }} + {...props} + > + {content} + + ); + } + + return ( + + } + onClick={onClick} + {...props} + > + {content} + + + setOpen(false)} /> + + + ); +} + +EditorLinkButton.displayName = 'Editor.LinkButton'; diff --git a/packages/raystack/components/editor/editor-link-form.tsx b/packages/raystack/components/editor/editor-link-form.tsx new file mode 100644 index 000000000..78d5e0b33 --- /dev/null +++ b/packages/raystack/components/editor/editor-link-form.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useState } from 'react'; +import { CheckIcon, ExternalLinkIcon, UnlinkIcon } from '~/icons'; +import { IconButton } from '../icon-button'; +import { Input } from '../input'; +import { activeLink, setLink, unsetLink } from './core/commands'; +import { isSafeHref, normalizeHref } from './core/link'; +import styles from './editor.module.css'; +import { useEditorStore } from './editor-context'; + +export interface EditorLinkFormProps { + /** Called after the link is applied or removed, or the form is cancelled. */ + onDone: () => void; +} + +/** The URL field behind `Editor.LinkButton`. */ +export function EditorLinkForm({ onDone }: EditorLinkFormProps) { + const store = useEditorStore('Editor.LinkButton'); + const [initial] = useState(() => activeLink(store.state)); + const [href, setHref] = useState(initial ?? ''); + + const normalized = normalizeHref(href); + const valid = normalized !== '' && isSafeHref(normalized); + + const finish = () => { + onDone(); + store.view?.focus(); + }; + + const apply = () => { + if (!valid) return; + store.run(setLink(normalized)); + finish(); + }; + + return ( +
+ setHref(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault(); + apply(); + } else if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + finish(); + } + }} + /> + + + + window.open(normalized, '_blank', 'noopener,noreferrer')} + > + + + {initial ? ( + { + store.run(unsetLink); + finish(); + }} + > + + + ) : null} +
+ ); +} diff --git a/packages/raystack/components/editor/editor-list-menu.tsx b/packages/raystack/components/editor/editor-list-menu.tsx new file mode 100644 index 000000000..8e6e64521 --- /dev/null +++ b/packages/raystack/components/editor/editor-list-menu.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { activeList, toggleList } from './core/commands'; +import { type EditorList, LIST_TYPES } from './core/schema'; +import { useEditorStore, useStoreSelector } from './editor-context'; +import type { EditorControlBaseProps } from './editor-control'; +import { BLOCK_DEFAULTS } from './editor-defaults'; +import { + EditorMenuControl, + EditorMenuItems, + type EditorMenuOption, + sameFlags +} from './editor-menu-control'; + +export interface EditorListMenuProps extends EditorControlBaseProps { + /** + * The list types in the menu. + * @default ['bulletList', 'orderedList', 'taskList'] + */ + types?: EditorList[]; +} + +/** A menu that turns the selection into a list, or back into text. */ +export function EditorListMenu({ + types = LIST_TYPES as EditorList[], + label = 'List', + ...props +}: EditorListMenuProps) { + const store = useEditorStore('Editor.ListMenu'); + const current = useStoreSelector(store, state => activeList(state.state)); + const editable = useStoreSelector(store, state => state.isEditable()); + + const available = types.filter(type => type in store.schema.nodes); + if (available.length === 0) return null; + + const Icon = BLOCK_DEFAULTS[current ?? 'bulletList'].Icon; + + return ( + } + disabled={!editable} + {...props} + > + + + ); +} + +EditorListMenu.displayName = 'Editor.ListMenu'; + +function ListOptions({ types }: { types: EditorList[] }) { + const store = useEditorStore('Editor.ListMenu'); + const current = useStoreSelector(store, state => activeList(state.state)); + const can = useStoreSelector( + store, + state => types.map(type => toggleList(type)(state.state)), + sameFlags + ); + + const options: EditorMenuOption[] = types.map((type, index) => { + const { action, ...rest } = BLOCK_DEFAULTS[type]; + return { + key: type, + ...rest, + shortcut: action && store.shortcuts[action], + active: current === type, + disabled: !can[index], + run: () => store.run(toggleList(type)) + }; + }); + + return ; +} diff --git a/packages/raystack/components/editor/editor-mark-button.tsx b/packages/raystack/components/editor/editor-mark-button.tsx new file mode 100644 index 000000000..cb2ca0fe9 --- /dev/null +++ b/packages/raystack/components/editor/editor-mark-button.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { isMarkActive, toggleMarkCommand } from './core/commands'; +import type { EditorMark } from './core/schema'; +import { useEditorStore, useStoreSelector } from './editor-context'; +import { EditorControl, type EditorControlBaseProps } from './editor-control'; +import { MARK_DEFAULTS } from './editor-defaults'; + +export interface EditorMarkButtonProps extends EditorControlBaseProps { + /** The mark the button toggles. */ + mark: EditorMark; +} + +/** Toggles a mark on the selection. */ +export function EditorMarkButton({ + mark, + label, + children, + onClick, + ...props +}: EditorMarkButtonProps) { + const store = useEditorStore('Editor.MarkButton'); + const active = useStoreSelector(store, current => + isMarkActive(current.state, mark) + ); + const enabled = useStoreSelector( + store, + current => current.isEditable() && toggleMarkCommand(mark)(current.state) + ); + + if (!(mark in store.schema.marks)) return null; + const { label: defaultLabel, Icon, action } = MARK_DEFAULTS[mark]; + + return ( + { + store.run(toggleMarkCommand(mark)); + onClick?.(event); + }} + {...props} + > + {children ?? } + + ); +} + +EditorMarkButton.displayName = 'Editor.MarkButton'; diff --git a/packages/raystack/components/editor/editor-mentions.tsx b/packages/raystack/components/editor/editor-mentions.tsx new file mode 100644 index 000000000..c8d1953ea --- /dev/null +++ b/packages/raystack/components/editor/editor-mentions.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { type ReactNode, useCallback, useEffect, useRef } from 'react'; +import { isTriggerCharacter, type MentionAttrs } from './core/mention'; +import type { + EditorMentionItem, + EditorMentionRef +} from './core/mention-registry'; +import { docMentions } from './core/serializers'; +import { SuggestionMenu } from './core/suggestion-menu'; +import { useMentionResolution } from './core/use-suggestion-menu'; +import { useEditorStore, useStoreSelector } from './editor-context'; +import type { EditorStore } from './editor-store'; +import { useEditorSuggestion } from './use-editor-suggestion'; + +export interface EditorMentionsProps { + /** + * The character that opens the menu. + * @default '@' + */ + trigger?: string; + /** Sync data, filtered on the label. */ + items?: EditorMentionItem[]; + /** Async data. Debounced, with superseded requests aborted through `signal`. Wins over `items`. */ + onSearch?: ( + query: string, + context: { trigger: string; signal: AbortSignal } + ) => Promise; + /** Fills in the icon, trailing content and label of chips loaded from `value` or `defaultValue`. */ + resolveMentions?: (refs: EditorMentionRef[]) => Promise; + /** + * Shown when nothing matches the query. + * @default 'No results' + */ + emptyMessage?: ReactNode; + /** + * Skeleton rows shown while `onSearch` is in flight. + * @default 3 + */ + loadingRowCount?: number; + /** Observes the menu's open state. */ + onOpenChange?: (open: boolean) => void; +} + +const NO_MENTIONS: MentionAttrs[] = []; + +function sameMentions(a: MentionAttrs[], b: MentionAttrs[]): boolean { + if (a.length !== b.length) return false; + return a.every( + (mention, index) => + mention.id === b[index].id && + mention.type === b[index].type && + mention.trigger === b[index].trigger && + mention.label === b[index].label + ); +} + +/** A menu that inserts a mention chip. Mount one per trigger. */ +export function EditorMentions(props: EditorMentionsProps) { + const store = useEditorStore('Editor.Mentions'); + // `formats` without `mention` builds a schema with no mention node. + if (!store.schema.nodes.mention) return null; + return ; +} + +EditorMentions.displayName = 'Editor.Mentions'; + +function MentionsMenu({ + store, + trigger = '@', + items, + onSearch, + resolveMentions, + emptyMessage = 'No results', + loadingRowCount = 3, + onOpenChange +}: EditorMentionsProps & { store: EditorStore }) { + const registry = store.mentions; + + if (process.env.NODE_ENV !== 'production' && !isTriggerCharacter(trigger)) { + console.warn( + `[Apsara] Editor.Mentions trigger ${JSON.stringify(trigger)} is not a ` + + 'single punctuation character. Use "@", "#" or similar.' + ); + } + + useEffect(() => registry.register(trigger), [registry, trigger]); + + const resolveRef = useRef(resolveMentions); + resolveRef.current = resolveMentions; + const getResolver = useCallback( + (chipTrigger: string) => + chipTrigger === trigger ? resolveRef.current : undefined, + [trigger] + ); + + const { menu, listboxId } = useEditorSuggestion({ + store, + trigger, + maxSpaces: 3, + items, + onSearch, + onSelect: (item, state) => { + const type = item.type ?? 'mention'; + registry.remember(state.trigger, { ...item, type }); + store.insertMentionAt( + { id: item.id, label: item.label, type, trigger: state.trigger }, + { from: state.from, to: state.to } + ); + }, + onOpenChange, + name: 'Editor.Mentions' + }); + + const mentions = useStoreSelector( + store, + current => + resolveMentions + ? docMentions(current.state.doc).filter( + mention => mention.trigger === trigger + ) + : NO_MENTIONS, + sameMentions + ); + useMentionResolution( + registry, + mentions, + store.refreshMentionLabels, + getResolver + ); + + return ( + menu.select(item as EditorMentionItem)} + onOpenChange={open => { + if (!open) menu.close(); + }} + loading={menu.loading} + loadingRowCount={loadingRowCount} + emptyMessage={emptyMessage} + /> + ); +} diff --git a/packages/raystack/components/editor/editor-menu-control.tsx b/packages/raystack/components/editor/editor-menu-control.tsx new file mode 100644 index 000000000..45ea13176 --- /dev/null +++ b/packages/raystack/components/editor/editor-menu-control.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { type ComponentType, type ReactNode, useMemo, useState } from 'react'; +import { CheckIcon, ChevronDownIcon } from '~/icons'; +import { Menu } from '../menu'; +import styles from './editor.module.css'; +import { useEditorStore } from './editor-context'; +import { + EditorControl, + type EditorControlBaseProps, + EditorShortcutKeys +} from './editor-control'; +import { useHoldFloatingToolbar } from './editor-floating-context'; + +export interface EditorMenuOption { + key: string; + label: string; + Icon: ComponentType; + shortcut?: string | false; + active: boolean; + disabled: boolean; + run: () => void; +} + +/** Compares the flags of a dry run, so an unchanged result skips the render. */ +export function sameFlags(a: boolean[], b: boolean[]): boolean { + return a.length === b.length && a.every((flag, index) => flag === b[index]); +} + +interface EditorMenuControlProps extends EditorControlBaseProps { + label: string; + trigger: ReactNode; + /** The rows. They render only while the menu is open. */ + children: ReactNode; +} + +/** A toolbar button that opens a menu of block types. */ +export function EditorMenuControl({ + label, + tooltip = true, + trigger, + disabled, + children, + ...props +}: EditorMenuControlProps) { + const store = useEditorStore('Editor.Toolbar'); + const [open, setOpen] = useState(false); + useHoldFloatingToolbar(open); + + // Focus goes back to the editor, which restores its selection. + const finalFocus = useMemo( + () => ({ + get current() { + return (store.view?.dom as HTMLElement | undefined) ?? null; + } + }), + [store] + ); + + return ( + + } + {...props} + > + {trigger} + + + + {children} + + + ); +} + +export function EditorMenuItems({ options }: { options: EditorMenuOption[] }) { + return options.map(option => ( + } + trailingIcon={ + + + + + } + onClick={option.run} + > + {option.label} + + )); +} diff --git a/packages/raystack/components/editor/editor-root.tsx b/packages/raystack/components/editor/editor-root.tsx new file mode 100644 index 000000000..3f44975e3 --- /dev/null +++ b/packages/raystack/components/editor/editor-root.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { mergeProps, useRender } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { + type RefObject, + useImperativeHandle, + useLayoutEffect, + useState +} from 'react'; +import type { EditorJSON } from './core/json'; +import { buildSchema, type EditorFormat } from './core/schema'; +import { isEditorEmpty } from './core/serializers'; +import { type EditorShortcuts, resolveShortcuts } from './core/shortcuts'; +import styles from './editor.module.css'; +import { EditorContext, useStoreSelector } from './editor-context'; +import { EditorStore } from './editor-store'; +import type { + EditorApi, + EditorChangeDetails, + EditorMarkdownChangeDetails, + MarkdownAdapter +} from './editor-types'; + +interface EditorBaseProps + extends Omit< + useRender.ComponentProps<'div'>, + 'defaultValue' | 'onChange' | 'placeholder' | 'autoFocus' + > { + /** Shows while the doc is empty. */ + placeholder?: string; + /** + * Allowlist of nodes and marks. Read once, when the editor is created. + * @default all formats + */ + formats?: EditorFormat[]; + /** Overrides a default key, or turns it off with `false`. Read once. */ + shortcuts?: EditorShortcuts; + /** + * Makes the editor not editable and disables the toolbars. + * @default false + */ + disabled?: boolean; + /** + * Makes the editor not editable and hides the toolbars and menus. + * @default false + */ + readOnly?: boolean; + /** + * Focuses the editor on mount, with the caret at the start or the end. + * @default false + */ + autoFocus?: boolean | 'start' | 'end'; + /** Imperative handle with commands and serializers. */ + actionsRef?: RefObject; +} + +type WithMarkdown = [M] extends [MarkdownAdapter] ? Yes : No; + +/** + * `M` is inferred from the `markdown` prop. With an adapter, `value` and + * `defaultValue` also take Markdown strings and the change details have + * `getMarkdown()`. + */ +export interface EditorProps< + M extends MarkdownAdapter | undefined = MarkdownAdapter | undefined +> extends EditorBaseProps { + /** Allows Markdown strings in `value` and `defaultValue`, and parses Markdown on paste. */ + markdown?: M; + /** Controlled document. A string is parsed as Markdown and needs `markdown`. */ + value?: WithMarkdown; + /** The first document when uncontrolled. Read once. */ + defaultValue?: WithMarkdown; + /** Fires once per doc change. It does not fire for changes made through `value`, or for labels from `resolveMentions`. */ + onValueChange?: ( + value: EditorJSON, + details: WithMarkdown + ) => void; +} + +export function EditorRoot({ + value, + defaultValue, + onValueChange, + markdown, + placeholder, + formats, + shortcuts, + disabled = false, + readOnly = false, + autoFocus = false, + actionsRef, + className, + render, + ref, + children, + ...props +}: EditorProps) { + const storeProps = { + placeholder, + disabled, + readOnly, + markdown, + onValueChange: onValueChange as EditorStore['props']['onValueChange'] + }; + + const [store] = useState( + () => + new EditorStore({ + schema: buildSchema(formats), + value: value ?? defaultValue, + shortcuts: resolveShortcuts(shortcuts), + props: storeProps, + autoFocus + }) + ); + + // Runs after every render, so callbacks stay current and flag changes reach + // the view before the next event. + useLayoutEffect(() => { + store.updateProps(storeProps); + }); + + useLayoutEffect(() => { + if (value !== undefined) store.reconcile(value); + }, [store, value]); + + useImperativeHandle(actionsRef, () => store.api, [store]); + + const focused = useStoreSelector(store, current => current.focused); + const empty = useStoreSelector(store, current => + isEditorEmpty(current.state.doc) + ); + + const element = useRender({ + defaultTagName: 'div', + ref, + render, + props: mergeProps<'div'>( + { + 'data-slot': 'editor', + className: cx(styles.root, className), + children, + 'data-focused': focused ? '' : undefined, + 'data-empty': empty ? '' : undefined, + 'data-disabled': disabled ? '' : undefined, + 'data-readonly': readOnly ? '' : undefined + } as useRender.ElementProps<'div'>, + props + ) + }); + + return {element}; +} + +EditorRoot.displayName = 'Editor'; diff --git a/packages/raystack/components/editor/editor-slash-items.tsx b/packages/raystack/components/editor/editor-slash-items.tsx new file mode 100644 index 000000000..53fcf2f60 --- /dev/null +++ b/packages/raystack/components/editor/editor-slash-items.tsx @@ -0,0 +1,191 @@ +import { activeTextStyle } from './core/commands'; +import type { EditorFormat, EditorHeadingLevel } from './core/schema'; +import type { EditorAction } from './core/shortcuts'; +import { + BLOCK_DEFAULTS, + HEADING_DEFAULTS, + PARAGRAPH_DEFAULT +} from './editor-defaults'; +import type { EditorApi, EditorSlashItem } from './editor-types'; + +interface Builtin { + /** The format the item needs. The menu hides the item without it. */ + format?: EditorFormat; + /** The action whose shortcut the row shows. */ + action?: EditorAction; + /** Whether the command can run at the caret, or the block already has its type. */ + applies: (editor: EditorApi) => boolean; +} + +function heading(level: EditorHeadingLevel): Builtin { + return { + format: 'heading', + action: `heading${level}`, + applies: editor => + editor.can.setHeading(level) || + activeTextStyle(editor.getState()) === level + }; +} + +// Keyed by `id`, so a copy of a built-in item, such as `{ ...item }`, keeps +// its shortcut and its format check. +const builtins = new Map([ + [ + 'paragraph', + { + action: 'paragraph', + applies: editor => + editor.can.setParagraph() || + activeTextStyle(editor.getState()) === 'paragraph' + } + ], + ['heading1', heading(1)], + ['heading2', heading(2)], + ['heading3', heading(3)], + [ + 'bulletList', + { + format: 'bulletList', + action: 'bulletList', + applies: editor => editor.can.toggleList('bulletList') + } + ], + [ + 'orderedList', + { + format: 'orderedList', + action: 'orderedList', + applies: editor => editor.can.toggleList('orderedList') + } + ], + [ + 'taskList', + { + format: 'taskList', + action: 'taskList', + applies: editor => editor.can.toggleList('taskList') + } + ], + [ + 'blockquote', + { + format: 'blockquote', + action: 'blockquote', + applies: editor => editor.can.toggleBlock('blockquote') + } + ], + [ + 'codeBlock', + { + format: 'codeBlock', + action: 'codeBlock', + applies: editor => editor.can.toggleBlock('codeBlock') + } + ], + [ + 'horizontalRule', + { + format: 'horizontalRule', + applies: editor => editor.can.insertHorizontalRule() + } + ] +]); + +const { Icon: TextIcon } = PARAGRAPH_DEFAULT; +const Heading1 = HEADING_DEFAULTS[1].Icon; +const Heading2 = HEADING_DEFAULTS[2].Icon; +const Heading3 = HEADING_DEFAULTS[3].Icon; +const BulletIcon = BLOCK_DEFAULTS.bulletList.Icon; +const NumberedIcon = BLOCK_DEFAULTS.orderedList.Icon; +const ChecklistIcon = BLOCK_DEFAULTS.taskList.Icon; +const QuoteIcon = BLOCK_DEFAULTS.blockquote.Icon; +const CodeBlockIcon = BLOCK_DEFAULTS.codeBlock.Icon; +const DividerIcon = BLOCK_DEFAULTS.horizontalRule.Icon; + +/** The built-in slash commands. Spread them to add your own. */ +export const defaultSlashItems: EditorSlashItem[] = [ + { + id: 'paragraph', + label: 'Text', + group: 'Text', + keywords: ['paragraph', 'plain'], + icon: , + run: editor => editor.commands.setParagraph() + }, + { + id: 'heading1', + label: 'Heading 1', + group: 'Text', + keywords: ['h1', 'title'], + icon: , + run: editor => editor.commands.setHeading(1) + }, + { + id: 'heading2', + label: 'Heading 2', + group: 'Text', + keywords: ['h2', 'subtitle'], + icon: , + run: editor => editor.commands.setHeading(2) + }, + { + id: 'heading3', + label: 'Heading 3', + group: 'Text', + keywords: ['h3'], + icon: , + run: editor => editor.commands.setHeading(3) + }, + { + id: 'bulletList', + label: 'Bulleted list', + group: 'Lists', + keywords: ['unordered', 'ul', 'bullet'], + icon: , + run: editor => editor.commands.toggleList('bulletList') + }, + { + id: 'orderedList', + label: 'Numbered list', + group: 'Lists', + keywords: ['ordered', 'ol'], + icon: , + run: editor => editor.commands.toggleList('orderedList') + }, + { + id: 'taskList', + label: 'Checklist', + group: 'Lists', + keywords: ['todo', 'task', 'checkbox'], + icon: , + run: editor => editor.commands.toggleList('taskList') + }, + { + id: 'blockquote', + label: 'Quote', + group: 'Blocks', + keywords: ['blockquote', 'citation'], + icon: , + run: editor => editor.commands.toggleBlock('blockquote') + }, + { + id: 'codeBlock', + label: 'Code block', + group: 'Blocks', + keywords: ['code', 'pre', 'snippet'], + icon: , + run: editor => editor.commands.toggleBlock('codeBlock') + }, + { + id: 'horizontalRule', + label: 'Divider', + group: 'Blocks', + keywords: ['hr', 'rule', 'separator', 'line'], + icon: , + run: editor => editor.commands.insertHorizontalRule() + } +]; + +export function builtinSlashItem(item: EditorSlashItem): Builtin | undefined { + return builtins.get(item.id); +} diff --git a/packages/raystack/components/editor/editor-slash-menu.tsx b/packages/raystack/components/editor/editor-slash-menu.tsx new file mode 100644 index 000000000..64be8f9bd --- /dev/null +++ b/packages/raystack/components/editor/editor-slash-menu.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { rankings } from '@tanstack/match-sorter-utils'; +import { type ReactNode, useMemo } from 'react'; +import { + SuggestionMenu, + type SuggestionMenuItem +} from './core/suggestion-menu'; +import { filterItems } from './core/use-suggestion-menu'; +import styles from './editor.module.css'; +import { useEditorStore, useStoreSelector } from './editor-context'; +import { EditorShortcutKeys } from './editor-control'; +import { sameFlags } from './editor-menu-control'; +import { builtinSlashItem, defaultSlashItems } from './editor-slash-items'; +import type { EditorSlashItem } from './editor-types'; +import { useEditorSuggestion } from './use-editor-suggestion'; + +export interface EditorSlashMenuProps { + /** + * The commands in the menu. + * @default defaultSlashItems + */ + items?: EditorSlashItem[]; + /** + * The character that opens the menu. + * @default '/' + */ + trigger?: string; + /** + * Shown when no command matches the query. + * @default 'No results' + */ + emptyMessage?: ReactNode; + /** Observes the menu's open state. */ + onOpenChange?: (open: boolean) => void; +} + +const NO_FLAGS: boolean[] = []; + +const slashAccessors = [ + (item: EditorSlashItem) => item.label, + (item: EditorSlashItem) => item.keywords ?? [], + // A description matches only at the start of a word, so a short query does + // not match most descriptions. + { + accessor: (item: EditorSlashItem) => item.description ?? '', + threshold: rankings.WORD_STARTS_WITH + } +]; + +function filterSlashItems( + items: EditorSlashItem[], + query: string +): EditorSlashItem[] { + return filterItems(items, query, slashAccessors); +} + +/** A menu of block commands that opens on `/`. */ +export function EditorSlashMenu({ + items = defaultSlashItems, + trigger = '/', + emptyMessage = 'No results', + onOpenChange +}: EditorSlashMenuProps) { + const store = useEditorStore('Editor.SlashMenu'); + + // Built-in commands for formats the schema does not have are left out. + const available = useMemo( + () => + items.filter(item => { + const format = builtinSlashItem(item)?.format; + return ( + !format || + format in store.schema.nodes || + format in store.schema.marks + ); + }), + [items, store] + ); + + // A built-in command that cannot run at the caret is disabled, as a heading + // is in the first paragraph of a list item. Checked only while the menu is + // open. + const blocked = useStoreSelector( + store, + current => + current.suggestion?.trigger === trigger + ? available.map( + item => !(builtinSlashItem(item)?.applies(current.api) ?? true) + ) + : NO_FLAGS, + sameFlags + ); + const menuItems = useMemo( + () => + available.map((item, index) => + blocked[index] && !item.disabled ? { ...item, disabled: true } : item + ), + [available, blocked] + ); + + const { menu, listboxId } = useEditorSuggestion({ + store, + trigger, + // A space ends the query, the way Linear's slash menu works. + maxSpaces: 0, + className: styles['slash-query'], + items: menuItems, + filter: filterSlashItems, + onSelect: (item, state) => { + store.runAfterDelete(state, () => item.run(store.api)); + }, + onOpenChange, + name: 'Editor.SlashMenu' + }); + + const groups = menu.groups.map(group => ({ + label: group.label, + items: group.items.map((item): SuggestionMenuItem => { + const action = builtinSlashItem(item)?.action; + const shortcut = + item.shortcut ?? (action ? store.shortcuts[action] : undefined); + return { + id: item.id, + label: item.label, + description: item.description, + icon: item.icon, + disabled: item.disabled, + trailing: shortcut ? : null + }; + }) + })); + + return ( + { + const item = menu.flat[index]; + if (item) menu.select(item); + }} + onOpenChange={open => { + if (!open) menu.close(); + }} + emptyMessage={emptyMessage} + /> + ); +} + +EditorSlashMenu.displayName = 'Editor.SlashMenu'; diff --git a/packages/raystack/components/editor/editor-store.ts b/packages/raystack/components/editor/editor-store.ts new file mode 100644 index 000000000..653dcccda --- /dev/null +++ b/packages/raystack/components/editor/editor-store.ts @@ -0,0 +1,814 @@ +import { baseKeymap } from 'prosemirror-commands'; +import { dropCursor } from 'prosemirror-dropcursor'; +import { gapCursor } from 'prosemirror-gapcursor'; +import { closeHistory, history, redo, undo } from 'prosemirror-history'; +import { keymap } from 'prosemirror-keymap'; +import { + Fragment, + type Node as PMNode, + type Schema, + Slice +} from 'prosemirror-model'; +import { + type Command, + EditorState, + NodeSelection, + Plugin, + Selection, + type Transaction +} from 'prosemirror-state'; +import type { EditorView } from 'prosemirror-view'; +import { EXTERNAL, placeholderPlugin } from './core/base'; +import { + activeLink, + activeTextStyle, + editorCommands, + isBlockActive, + isMarkActive, + setLink +} from './core/commands'; +import { buildInputRules } from './core/input-rules'; +import { docFromJSON, type EditorJSON, emptyDoc } from './core/json'; +import { buildEditingKeymap } from './core/keymaps'; +import { type MentionAttrs, mentionKey } from './core/mention'; +import { + MentionNodeView, + type MentionPortal, + type MentionPortalRegistry +} from './core/mention-node-view'; +import { MentionRegistry } from './core/mention-registry'; +import type { EditorFormat } from './core/schema'; +import { + docToHTML, + docToText, + isEditorEmpty, + showsPlaceholder +} from './core/serializers'; +import type { EditorAction } from './core/shortcuts'; +import { + mentionTransaction, + type SuggestionState, + type SuggestionTrigger, + suggestionPlugin, + suggestionPluginKey +} from './core/suggestion-plugin'; +import { TaskItemView } from './core/task-item-view'; +import styles from './editor.module.css'; +import type { + EditorApi, + EditorChangeDetails, + EditorCommands, + EditorMarkdownChangeDetails, + MarkdownAdapter +} from './editor-types'; + +export interface EditorStoreProps { + placeholder?: string; + disabled: boolean; + readOnly: boolean; + markdown?: MarkdownAdapter; + onValueChange?: ( + value: EditorJSON, + details: EditorChangeDetails | EditorMarkdownChangeDetails + ) => void; +} + +export interface EditorTriggerEntry extends SuggestionTrigger { + onKeyDown: (event: KeyboardEvent, state: SuggestionState) => boolean; +} + +/** What `Editor.Content` needs from an open menu for its combobox ARIA. */ +export interface EditorMenuAria { + listboxId: string; + activeOptionId?: string; +} + +interface Target { + priority: number; + run: () => boolean; +} + +const URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i; + +// Dragging selected text ends with `dragend`, not `mouseup`. +const POINTER_END_EVENTS = ['mouseup', 'pointerup', 'pointercancel', 'dragend']; + +function run(targets: Set): boolean { + const sorted = [...targets].sort((a, b) => b.priority - a.priority); + return sorted.some(target => target.run()); +} + +function register(targets: Set, target: Target): () => void { + targets.add(target); + return () => { + targets.delete(target); + }; +} + +/** + * Pasted Markdown. A single paragraph pastes as inline content and takes the + * marks at the caret, as a plain-text paste does. + */ +function pastedSlice(state: EditorState, doc: PMNode): Slice { + const first = doc.firstChild; + if (doc.childCount !== 1 || first?.type !== state.schema.nodes.paragraph) { + return Slice.maxOpen(doc.content); + } + const marks = state.storedMarks ?? state.selection.$from.marks(); + const nodes: PMNode[] = []; + first.forEach(node => { + let set = node.marks; + for (const mark of marks) { + if (!mark.type.isInSet(set)) set = mark.addToSet(set); + } + nodes.push(node.mark(set)); + }); + return new Slice(Fragment.from(nodes), 0, 0); +} + +/** + * The editor's state, view and wiring. `Editor` creates one, and every part + * reads it through context. The state lives here and not in the view, so a + * remounted `Editor.Content` keeps the doc and the history. + */ +export class EditorStore { + readonly schema: Schema; + readonly shortcuts: Record; + readonly mentions = new MentionRegistry(); + readonly api: EditorApi; + /** Read by `Editor.Content` when it mounts. */ + readonly autoFocus: boolean | 'start' | 'end'; + + state: EditorState; + view: EditorView | null = null; + props: EditorStoreProps; + + focused = false; + pointerSelecting = false; + suggestion: SuggestionState | null = null; + mentionPortals: MentionPortal[] = []; + menus = new Map(); + /** Bumped by every change a part can observe. */ + revision = 0; + + /** The last value this store emitted or loaded. */ + private lastValue: EditorJSON | string | undefined; + /** Markdown for docs the adapter has converted, so a controlled string that matches the doc does not reload it. */ + private readonly markdownCache = new WeakMap(); + private triggers = new Map(); + private toolbars = new Set(); + private linkOpeners = new Set(); + private listeners = new Set<() => void>(); + private taskViews = new Set(); + private group: Transaction | null = null; + + readonly portalRegistry: MentionPortalRegistry = { + add: portal => { + this.mentionPortals = [...this.mentionPortals, portal]; + this.emit(); + }, + update: (id, attrs) => { + this.mentionPortals = this.mentionPortals.map(portal => + portal.id === id ? { ...portal, attrs } : portal + ); + this.emit(); + }, + remove: id => { + this.mentionPortals = this.mentionPortals.filter( + portal => portal.id !== id + ); + this.emit(); + } + }; + + readonly nodeViews = { + mention: (node: PMNode) => new MentionNodeView(node, this.portalRegistry), + taskItem: ( + node: PMNode, + view: EditorView, + getPos: () => number | undefined + ) => { + const task: TaskItemView = new TaskItemView( + node, + view, + getPos, + { + item: styles['task-item'], + checkbox: styles['task-checkbox'], + content: styles['task-content'] + }, + () => { + this.taskViews.delete(task); + } + ); + this.taskViews.add(task); + return task; + } + }; + + constructor(options: { + schema: Schema; + value: EditorJSON | string | undefined; + shortcuts: Record; + props: EditorStoreProps; + autoFocus: boolean | 'start' | 'end'; + }) { + this.schema = options.schema; + this.shortcuts = options.shortcuts; + this.props = options.props; + this.autoFocus = options.autoFocus; + this.lastValue = options.value; + const doc = + options.value === undefined + ? emptyDoc(this.schema) + : this.parse(options.value); + this.state = EditorState.create({ + doc, + plugins: this.plugins() + }); + this.api = this.createApi(); + } + + // ---- subscription ---- + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + emit(): void { + this.revision += 1; + for (const listener of this.listeners) listener(); + } + + // ---- state ---- + + isEditable(): boolean { + return !this.props.disabled && !this.props.readOnly; + } + + dispatch = (tr: Transaction): void => { + if (this.group && tr.docChanged && !tr.getMeta('appendedTransaction')) { + // Joins the history event of the transaction that started the group. + tr.setMeta('appendedTransaction', this.group); + } + const next = this.state.apply(tr); + this.state = next; + this.view?.updateState(next); + if (tr.docChanged && !tr.getMeta(EXTERNAL)) this.report(next.doc); + this.emit(); + }; + + run = (command: Command): boolean => + command(this.state, this.dispatch, this.view ?? undefined); + + private report(doc: PMNode): void { + const onValueChange = this.props.onValueChange; + if (!onValueChange) { + this.lastValue = undefined; + return; + } + const value = doc.toJSON() as EditorJSON; + this.lastValue = value; + onValueChange(value, this.details(doc, value)); + } + + private details( + doc: PMNode, + value: EditorJSON + ): EditorChangeDetails | EditorMarkdownChangeDetails { + const details: EditorChangeDetails = { + empty: isEditorEmpty(doc), + getMentions: () => docToText(doc).mentions, + getText: () => docToText(doc).text, + getHTML: () => docToHTML(doc) + }; + const adapter = this.props.markdown; + if (!adapter) return details; + return { + ...details, + getMarkdown: () => this.markdownOf(doc, adapter, value) + }; + } + + private markdownOf( + doc: PMNode, + adapter: MarkdownAdapter, + value?: EditorJSON + ): string { + let markdown = this.markdownCache.get(doc); + if (markdown === undefined) { + markdown = adapter.fromEditor(value ?? (doc.toJSON() as EditorJSON)); + this.markdownCache.set(doc, markdown); + } + return markdown; + } + + /** Loads a `value` or `defaultValue`. A string is Markdown. */ + parse(value: EditorJSON | string): PMNode { + if (typeof value !== 'string') return docFromJSON(this.schema, value); + const adapter = this.props.markdown; + if (adapter) return docFromJSON(this.schema, adapter.toEditor(value)); + if (process.env.NODE_ENV !== 'production') { + console.warn( + '[Apsara] Editor received a string value without the `markdown` ' + + 'prop. The string loads as plain text.' + ); + } + const paragraphs = value + .split(/\n{2,}/) + .map(text => + this.schema.nodes.paragraph.create( + null, + text ? this.schema.text(text) : null + ) + ); + return this.schema.topNodeType.create(null, paragraphs); + } + + /** + * Applies a controlled `value`. Nothing happens when it is the value the + * store last emitted, when a Markdown string is what the doc converts to, or + * when it loads to the doc the editor already has. + */ + reconcile(value: EditorJSON | string): void { + if (value === this.lastValue) return; + this.lastValue = value; + const adapter = this.props.markdown; + if ( + typeof value === 'string' && + adapter && + this.markdownOf(this.state.doc, adapter) === value + ) { + return; + } + const doc = this.parse(value); + if (doc.eq(this.state.doc)) return; + this.replaceState(doc); + } + + /** Loads `doc` with a new history, so Undo cannot step back past it. */ + private replaceState(doc: PMNode): void { + const pos = Math.min(this.state.selection.from, doc.content.size); + this.state = EditorState.create({ + doc, + plugins: this.state.plugins, + selection: Selection.near(doc.resolve(pos)) + }); + this.view?.updateState(this.state); + this.emit(); + } + + private replaceDocTransaction(doc: PMNode): Transaction { + const tr = this.state.tr.replaceWith( + 0, + this.state.doc.content.size, + doc.content + ); + const pos = Math.min(this.state.selection.from, tr.doc.content.size); + tr.setSelection(Selection.near(tr.doc.resolve(pos))); + return tr; + } + + updateProps(next: EditorStoreProps): void { + const previous = this.props; + this.props = next; + if ( + previous.disabled !== next.disabled || + previous.readOnly !== next.readOnly + ) { + this.view?.setProps({ editable: () => this.isEditable() }); + for (const task of this.taskViews) task.setEditable(this.isEditable()); + this.emit(); + } + if (previous.placeholder !== next.placeholder) { + // Redraws the placeholder decoration. The doc does not change. + this.dispatch(this.state.tr.setMeta(EXTERNAL, true)); + } + } + + // ---- view ---- + + private endPointerSelection = () => { + if (!this.pointerSelecting) return; + this.pointerSelecting = false; + this.emit(); + }; + + attachView(view: EditorView): void { + if ( + process.env.NODE_ENV !== 'production' && + this.view && + this.view !== view + ) { + console.warn( + '[Apsara] Editor has more than one Editor.Content. Render one per Editor.' + ); + } + this.view = view; + const ownerDocument = view.dom.ownerDocument; + for (const type of POINTER_END_EVENTS) { + ownerDocument.addEventListener(type, this.endPointerSelection); + } + ownerDocument.defaultView?.addEventListener( + 'blur', + this.endPointerSelection + ); + this.emit(); + } + + detachView(view: EditorView): void { + if (this.view !== view) return; + const ownerDocument = view.dom.ownerDocument; + for (const type of POINTER_END_EVENTS) { + ownerDocument.removeEventListener(type, this.endPointerSelection); + } + ownerDocument.defaultView?.removeEventListener( + 'blur', + this.endPointerSelection + ); + this.view = null; + this.focused = false; + this.pointerSelecting = false; + this.emit(); + } + + focus(position?: 'start' | 'end'): void { + const view = this.view; + if (!view) return; + view.focus(); + if (!position) return; + const selection = + position === 'start' + ? Selection.atStart(this.state.doc) + : Selection.atEnd(this.state.doc); + this.dispatch(this.state.tr.setSelection(selection).scrollIntoView()); + } + + // ---- plugins ---- + + private plugins(): Plugin[] { + const inputRules = buildInputRules(this.schema); + const plugins = [ + // First, so an open menu wins arrows, Enter, Tab and Escape. + suggestionPlugin({ + getTriggers: () => [...this.triggers.values()], + onStateChange: next => { + this.suggestion = next; + this.emit(); + }, + onKeyDown: (event, state) => + this.triggers.get(state.trigger)?.onKeyDown(event, state) ?? false + }), + keymap(this.shortcutBindings()), + buildEditingKeymap(this.schema, { + redo: this.shortcuts.redo !== false + }), + history(), + keymap(baseKeymap), + dropCursor({ class: styles['drop-cursor'], color: false }), + gapCursor(), + placeholderPlugin(() => this.props.placeholder, showsPlaceholder), + this.eventsPlugin() + ]; + if (inputRules) plugins.splice(3, 0, inputRules); + return plugins; + } + + private shortcutBindings(): Record { + const commands: Record = { + bold: editorCommands.toggleMark('bold'), + italic: editorCommands.toggleMark('italic'), + underline: editorCommands.toggleMark('underline'), + strike: editorCommands.toggleMark('strike'), + code: editorCommands.toggleMark('code'), + link: () => this.openLink(), + paragraph: editorCommands.setParagraph(), + heading1: editorCommands.setHeading(1), + heading2: editorCommands.setHeading(2), + heading3: editorCommands.setHeading(3), + heading4: editorCommands.setHeading(4), + bulletList: editorCommands.toggleList('bulletList'), + orderedList: editorCommands.toggleList('orderedList'), + taskList: editorCommands.toggleList('taskList'), + blockquote: editorCommands.toggleBlock('blockquote'), + codeBlock: editorCommands.toggleBlock('codeBlock'), + undo, + redo, + focusToolbar: () => this.focusToolbar() + }; + const bindings: Record = {}; + for (const [action, key] of Object.entries(this.shortcuts)) { + if (key) bindings[key] = commands[action as EditorAction]; + } + return bindings; + } + + private eventsPlugin(): Plugin { + return new Plugin({ + props: { + handleDOMEvents: { + focus: () => { + this.focused = true; + this.emit(); + return false; + }, + blur: () => { + this.focused = false; + this.emit(); + return false; + }, + mousedown: (_view, event) => { + if (event.button !== 0) return false; + this.pointerSelecting = true; + this.emit(); + return false; + } + }, + handlePaste: (view, event) => { + const data = event.clipboardData; + if (!data) return false; + const text = data.getData('text/plain'); + const url = text.trim(); + + // A URL pasted over a selection links the selection. + if ( + !view.state.selection.empty && + URL_PATTERN.test(url) && + setLink(url)(view.state, view.dispatch) + ) { + return true; + } + + const adapter = this.props.markdown; + const { $from } = view.state.selection; + const marks = view.state.storedMarks ?? $from.marks(); + if ( + !adapter || + adapter.paste === false || + !text || + data.types.includes('text/html') || + $from.parent.type.spec.code || + marks.some(mark => mark.type.spec.code) + ) { + return false; + } + const doc = docFromJSON(this.schema, adapter.toEditor(text)); + view.dispatch( + view.state.tr + .replaceSelection(pastedSlice(view.state, doc)) + .scrollIntoView() + ); + return true; + }, + clipboardTextSerializer: (slice, view) => { + const adapter = this.props.markdown; + // A copied slice includes its parents up to the doc. + const doc = adapter?.copy + ? view.state.schema.topNodeType.createAndFill(null, slice.content) + : null; + if (adapter && doc) { + return adapter.fromEditor(doc.toJSON() as EditorJSON); + } + return slice.content.textBetween(0, slice.content.size, '\n\n'); + } + } + }); + } + + // ---- menus, toolbars and links ---- + + registerTrigger(entry: EditorTriggerEntry): () => void { + if ( + process.env.NODE_ENV !== 'production' && + this.triggers.has(entry.char) + ) { + console.warn( + `[Apsara] Editor has two menus for the trigger ${JSON.stringify( + entry.char + )}. Only the last one opens.` + ); + } + this.triggers.set(entry.char, entry); + this.emit(); + return () => { + if (this.triggers.get(entry.char) !== entry) return; + this.triggers.delete(entry.char); + this.menus.delete(entry.char); + this.emit(); + }; + } + + hasTriggers(): boolean { + return this.triggers.size > 0; + } + + setMenu(trigger: string, aria: EditorMenuAria | null): void { + const current = this.menus.get(trigger); + if (!aria) { + if (!current) return; + this.menus.delete(trigger); + this.emit(); + return; + } + if ( + current?.listboxId === aria.listboxId && + current.activeOptionId === aria.activeOptionId + ) { + return; + } + this.menus.set(trigger, aria); + this.emit(); + } + + dismissSuggestion = (): void => { + if (!this.suggestion) return; + this.dispatch( + this.state.tr.setMeta(suggestionPluginKey, { type: 'dismiss' }) + ); + }; + + /** + * Deletes the typed `/query`, then runs `action`. One undo restores the + * query: the delete starts a history event and the action's transactions + * join it. + */ + runAfterDelete( + range: { from: number; to: number }, + action: () => void + ): void { + const tr = closeHistory(this.state.tr.delete(range.from, range.to)); + this.dispatch(tr); + this.group = tr; + try { + action(); + } finally { + this.group = null; + } + this.view?.focus(); + } + + insertMentionAt( + attrs: MentionAttrs, + range?: { from: number; to: number } + ): void { + const tr = mentionTransaction(this.state, attrs, range); + if (!tr) return; + this.dispatch(tr); + this.view?.focus(); + } + + /** + * Applies fresh labels from `resolveMentions`. A label update is not an + * edit, so it skips history and `onValueChange`. + */ + refreshMentionLabels = (labels: Map): void => { + if (labels.size === 0) return; + const tr = this.state.tr; + let changed = false; + this.state.doc.descendants((node, pos) => { + if (node.type.name !== 'mention') return; + const attrs = node.attrs as MentionAttrs; + const fresh = labels.get(mentionKey(attrs.trigger, attrs.type, attrs.id)); + if (fresh && fresh !== attrs.label) { + tr.setNodeMarkup(pos, undefined, { ...attrs, label: fresh }); + changed = true; + } + }); + if (!changed) return; + tr.setMeta(EXTERNAL, true); + tr.setMeta('addToHistory', false); + this.dispatch(tr); + }; + + registerToolbar(priority: number, focus: () => boolean): () => void { + return register(this.toolbars, { priority, run: focus }); + } + + focusToolbar(): boolean { + return run(this.toolbars); + } + + registerLinkOpener(priority: number, open: () => boolean): () => void { + return register(this.linkOpeners, { priority, run: open }); + } + + openLink(): boolean { + if (!this.isEditable() || !this.schema.marks.link) return false; + return run(this.linkOpeners); + } + + // ---- api ---- + + private commandSet( + runCommand: (command: Command) => boolean + ): EditorCommands { + return { + toggleMark: mark => runCommand(editorCommands.toggleMark(mark)), + setParagraph: () => runCommand(editorCommands.setParagraph()), + setHeading: level => runCommand(editorCommands.setHeading(level)), + toggleBlock: block => runCommand(editorCommands.toggleBlock(block)), + toggleList: list => runCommand(editorCommands.toggleList(list)), + setLink: href => runCommand(editorCommands.setLink(href)), + unsetLink: () => runCommand(editorCommands.unsetLink()), + insertHorizontalRule: () => + runCommand(editorCommands.insertHorizontalRule()), + insertText: text => runCommand(editorCommands.insertText(text)), + insertMention: (item, options) => + runCommand((state, dispatch) => { + if (!state.schema.nodes.mention) return false; + if (!dispatch) return true; + const trigger = + options?.trigger ?? this.mentions.triggers()[0] ?? '@'; + const type = item.type ?? 'mention'; + this.mentions.remember(trigger, { ...item, type }); + const tr = mentionTransaction(state, { + id: item.id, + label: item.label, + type, + trigger + }); + if (tr) dispatch(tr); + return true; + }), + clearFormatting: () => runCommand(editorCommands.clearFormatting()), + undo: () => runCommand(undo), + redo: () => runCommand(redo), + setContent: value => + runCommand((state, dispatch) => { + if (dispatch) dispatch(this.replaceDocTransaction(this.parse(value))); + return true; + }), + clear: () => + runCommand((state, dispatch) => { + if (dispatch) { + dispatch(this.replaceDocTransaction(emptyDoc(state.schema))); + } + return true; + }) + }; + } + + private createApi(): EditorApi { + const store = this; + return { + get view() { + return store.view; + }, + getState: () => store.state, + commands: this.commandSet(store.run), + can: this.commandSet(command => command(store.state)), + isActive: (format: EditorFormat, attrs?: Record) => + isFormatActive(store.state, format, attrs), + getJSON: () => store.state.doc.toJSON() as EditorJSON, + getText: () => docToText(store.state.doc).text, + getHTML: () => docToHTML(store.state.doc), + getMarkdown: () => { + const adapter = store.props.markdown; + if (!adapter) { + if (process.env.NODE_ENV !== 'production') { + console.error( + '[Apsara] Editor getMarkdown() needs the `markdown` prop. ' + + 'Pass MarkdownAdapter.create().' + ); + } + return null; + } + return store.markdownOf(store.state.doc, adapter); + }, + focus: position => store.focus(position) + }; + } +} + +export function isFormatActive( + state: EditorState, + format: EditorFormat, + attrs?: Record +): boolean { + switch (format) { + case 'bold': + case 'italic': + case 'underline': + case 'strike': + case 'code': + return isMarkActive(state, format); + case 'link': + return activeLink(state) !== null; + case 'heading': { + const style = activeTextStyle(state); + if (typeof style !== 'number') return false; + return attrs?.level === undefined || attrs.level === style; + } + case 'mention': + case 'horizontalRule': + return ( + state.selection instanceof NodeSelection && + state.selection.node.type.name === format + ); + default: + return isBlockActive(state, format); + } +} diff --git a/packages/raystack/components/editor/editor-toolbar.tsx b/packages/raystack/components/editor/editor-toolbar.tsx new file mode 100644 index 000000000..32a4e05e4 --- /dev/null +++ b/packages/raystack/components/editor/editor-toolbar.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { useMergedRefs } from '@base-ui/utils/useMergedRefs'; +import { cx } from 'class-variance-authority'; +import { type ComponentProps, useEffect, useRef } from 'react'; +import { Toolbar } from '../toolbar'; +import { Tooltip } from '../tooltip'; +import styles from './editor.module.css'; +import { useEditorStore, useStoreSelector } from './editor-context'; + +export type EditorToolbarProps = ComponentProps; + +/** Focuses the first control in a toolbar that can take focus. */ +export function focusFirstControl(root: HTMLElement | null): boolean { + const control = root?.querySelector( + 'button:not([disabled]):not([aria-disabled="true"]), input:not([disabled])' + ); + if (!control) return false; + control.focus(); + return true; +} + +/** A toolbar that stays in place, above or below the content. */ +export function EditorToolbar({ + className, + ref, + 'aria-label': ariaLabel = 'Formatting', + ...props +}: EditorToolbarProps) { + const store = useEditorStore('Editor.Toolbar'); + const rootRef = useRef(null); + const mergedRef = useMergedRefs(rootRef, ref); + const readOnly = useStoreSelector(store, current => current.props.readOnly); + const disabled = useStoreSelector(store, current => current.props.disabled); + + useEffect( + () => store.registerToolbar(0, () => focusFirstControl(rootRef.current)), + [store] + ); + + if (readOnly) return null; + + return ( + + + + ); +} + +EditorToolbar.displayName = 'Editor.Toolbar'; diff --git a/packages/raystack/components/editor/editor-types.ts b/packages/raystack/components/editor/editor-types.ts new file mode 100644 index 000000000..7c0855d8f --- /dev/null +++ b/packages/raystack/components/editor/editor-types.ts @@ -0,0 +1,97 @@ +import type { EditorState } from 'prosemirror-state'; +import type { EditorView } from 'prosemirror-view'; +import type { ReactNode } from 'react'; +import type { EditorJSON } from './core/json'; +import type { EditorMention } from './core/mention'; +import type { EditorMentionItem } from './core/mention-registry'; +import type { + EditorFormat, + EditorHeadingLevel, + EditorList, + EditorMark +} from './core/schema'; + +export interface EditorCommands { + toggleMark: (mark: EditorMark) => boolean; + setParagraph: () => boolean; + setHeading: (level: EditorHeadingLevel) => boolean; + toggleBlock: (block: 'blockquote' | 'codeBlock') => boolean; + toggleList: (list: EditorList) => boolean; + setLink: (href: string) => boolean; + unsetLink: () => boolean; + insertHorizontalRule: () => boolean; + insertText: (text: string) => boolean; + insertMention: ( + item: EditorMentionItem, + options?: { trigger?: string } + ) => boolean; + clearFormatting: () => boolean; + undo: () => boolean; + redo: () => boolean; + setContent: (value: EditorJSON) => boolean; + clear: () => boolean; +} + +export interface EditorApi { + /** Null until `Editor.Content` mounts. */ + readonly view: EditorView | null; + getState: () => EditorState; + /** Runs a command. Returns true if it applied. */ + commands: EditorCommands; + /** Dry runs a command. Nothing is dispatched. */ + can: EditorCommands; + isActive: (format: EditorFormat, attrs?: Record) => boolean; + getJSON: () => EditorJSON; + getText: () => string; + getHTML: () => string; + /** Null, with a dev console error, when the `markdown` prop is not set. */ + getMarkdown: () => string | null; + focus: (position?: 'start' | 'end') => void; +} + +export interface EditorChangeDetails { + /** One paragraph that holds only whitespace. */ + empty: boolean; + /** Collects the mentions in the doc from this change, in document order. */ + getMentions: () => EditorMention[]; + /** Converts the doc from this change to plain text. Mentions read as `@label`. */ + getText: () => string; + /** Converts the doc from this change to HTML. */ + getHTML: () => string; +} + +export type EditorMarkdownChangeDetails = EditorChangeDetails & { + /** Converts the doc from this change to Markdown with the editor's adapter. */ + getMarkdown: () => string; +}; + +/** Converts between Markdown and editor JSON. */ +export interface MarkdownAdapter { + toEditor: (markdown: string) => EditorJSON; + fromEditor: (value: EditorJSON) => string; + /** + * Parses plain-text Markdown on paste. + * @default true + */ + readonly paste?: boolean; + /** + * Writes Markdown to `text/plain` on copy. + * @default false + */ + readonly copy?: boolean; +} + +export interface EditorSlashItem { + id: string; + label: string; + /** Shown under the label. The filter matches it at the start of a word. */ + description?: string; + icon?: ReactNode; + group?: string; + keywords?: string[]; + /** Shortcut shown on the row, in ProseMirror keymap syntax. Built-in commands show their default key. */ + shortcut?: string; + disabled?: boolean; + /** Runs after the menu removes the typed `/query`. */ + run: (editor: EditorApi) => void; +} diff --git a/packages/raystack/components/editor/editor.module.css b/packages/raystack/components/editor/editor.module.css index 03d6e10fe..deec38a36 100644 --- a/packages/raystack/components/editor/editor.module.css +++ b/packages/raystack/components/editor/editor.module.css @@ -1,176 +1,278 @@ -/* The editing host. A contentEditable div grows with its content natively, so - there is no `field-sizing` here, since that property applies to form controls. */ -.editor { - /* One line box has to be tall enough to hold a whole chip, or inserting one - would grow the composer and nudge the page under it. The small font's own - 16px line box is shorter than a chip's icon and trailing slots, so the - editor pairs the small font with the regular line height and sizes the chip - to match, so the line box is the same height whatever a line holds. */ +.root { + display: flex; + flex-direction: column; + gap: var(--rs-space-3); + min-width: 0; +} + +/* ---- Content ---- */ + +/* The ProseMirror base rules come from `.editor` in editor-core.module.css. */ +.content { + /* The mention chip sizes itself to this line height. */ --editor-line-height: var(--rs-line-height-regular); - box-sizing: border-box; - /* ProseMirror positions widget decorations against the editing host. */ - position: relative; - width: 100%; - outline: none; - white-space: pre-wrap; - overflow-wrap: break-word; - word-break: break-word; - overflow-y: auto; + min-height: var(--rs-space-8); color: var(--rs-color-foreground-base-primary); - font-family: var(--rs-font-family); - font-size: var(--rs-font-size-small); + font-family: var(--rs-font-body); + font-size: var(--rs-font-size-regular); line-height: var(--editor-line-height); - letter-spacing: var(--rs-letter-spacing-small); + letter-spacing: var(--rs-letter-spacing-regular); +} + +.content[aria-disabled="true"] { + color: var(--rs-color-foreground-base-secondary); } -.editor > p { +/* Zero specificity, so the spacing rules below win over the reset. */ +.content :where(p, h1, h2, h3, h4, ul, ol, blockquote, pre) { margin: 0; } -/* prosemirror-view ships a stylesheet the editor deliberately does not load, - it brings its own type, layout and selection treatment. Two of those rules - are load-bearing rather than cosmetic, so they are mirrored here. */ +.content > * + * { + margin-top: var(--rs-space-3); +} -/* ProseMirror appends a zero-size placeholder after a paragraph that ends - in a `contentEditable="false"` node, a chip, so the browser can draw a - caret past it. An app-level `img { display: block }` reset (Tailwind's - preflight carries one) turns that placeholder into a block box, which reads - as a phantom empty line under the chip and swallows a Backspace. */ -.editor :global(img.ProseMirror-separator) { - display: inline !important; - border: none !important; - margin: 0 !important; +.content h1, +.content h2, +.content h3, +.content h4 { + font-family: var(--rs-font-title); + font-weight: var(--rs-font-weight-medium); } -/* A selected chip is a NodeSelection, so the text caret has to go away while - it is up, the way it does for any other selected object. */ -.editor:global(.ProseMirror-hideselection) { - caret-color: transparent; +.content h1 { + font-size: var(--rs-font-size-t2); + line-height: var(--rs-line-height-t2); + letter-spacing: var(--rs-letter-spacing-t2); } -.editor:global(.ProseMirror-hideselection) *::selection { - background: transparent; +.content h2 { + font-size: var(--rs-font-size-t1); + line-height: var(--rs-line-height-t1); + letter-spacing: var(--rs-letter-spacing-t1); } -/* Read from the same emptiness predicate as `data-empty`, a ProseMirror-empty - paragraph still holds a trailing
, so `:empty` would never match. */ -.placeholder::before { - content: attr(data-placeholder); - float: left; - height: 0; - pointer-events: none; - color: var(--rs-color-foreground-base-tertiary); +.content h3 { + font-size: var(--rs-font-size-large); + line-height: var(--rs-line-height-large); + letter-spacing: var(--rs-letter-spacing-large); +} + +.content > h1:not(:first-child), +.content > h2:not(:first-child), +.content > h3:not(:first-child) { + margin-top: var(--rs-space-5); +} + +.content ul, +.content ol { + padding-left: var(--rs-space-6); +} + +.content li + li, +.content li > * + *, +.task-content > * + * { + margin-top: var(--rs-space-2); } -/* The subtle fill on the typed trigger and its query while the menu is open, so - a pending mention reads as pending rather than as ordinary text. */ -.activeSuggestion { - border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); - background: var(--rs-color-background-base-primary-hover); +/* The label and the content sit side by side, so the content gets no top + margin from the rule above. */ +.task-item > .task-content { + margin-top: 0; } -/* Chip. Chip's tokens on the editor's own type scale, so the line box does not - jump, and inert, since removal is editing-only, so there is no hover or active - treatment to advertise a press that does nothing. */ -.mention { +.content ul[data-type="taskList"] { + padding-left: 0; + list-style: none; +} + +.task-item { + display: flex; + align-items: flex-start; + gap: var(--rs-space-3); +} + +.task-item > label { display: inline-flex; align-items: center; - gap: var(--rs-space-1); - box-sizing: border-box; - /* Fixed to the line box's height, so nothing the chip holds can change the - composer's height as it lands: an icon portals in one commit after the chip - itself, and a consumer's trailing badge is taller than the label. */ height: var(--editor-line-height); - max-width: 16rem; - padding: 0 var(--rs-space-2); - border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); - background: var(--rs-color-background-base-primary-hover); - color: var(--rs-color-foreground-base-primary); - font-size: inherit; - line-height: inherit; - letter-spacing: inherit; - /* Top-aligned rather than baseline-aligned: an inline-flex box takes its - baseline from its *first* flex item, so a chip with an icon would hang off - the icon's box and ride above the surrounding text. Its height matches the - line box, so aligning the two tops puts the label on the text's baseline. */ - vertical-align: top; - white-space: nowrap; user-select: none; +} + +.task-checkbox { + width: var(--rs-space-4); + height: var(--rs-space-4); + margin: 0; + accent-color: var(--rs-color-background-accent-emphasis); + cursor: pointer; +} + +.task-checkbox:disabled { cursor: default; } -.mentionLabel { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.task-content { + flex: 1; + min-width: 0; } -.mentionIcon, -.mentionTrailing { - display: inline-flex; - align-items: center; - flex-shrink: 0; +.task-item[data-checked="true"] > .task-content { + color: var(--rs-color-foreground-base-tertiary); + text-decoration: line-through; +} + +.content blockquote { + padding-left: var(--rs-space-4); + border-left: 2px solid var(--rs-color-border-base-primary); color: var(--rs-color-foreground-base-secondary); } -/* The slots are portal targets and stay in the DOM even with nothing in them. */ -.mentionIcon:empty, -.mentionTrailing:empty { +.content pre { + padding: var(--rs-space-3) var(--rs-space-4); + border-radius: var(--rs-radius-2); + background: var(--rs-color-background-base-secondary); + overflow-x: auto; + white-space: pre; +} + +.content code { + font-family: var(--rs-font-mono); + font-size: var(--rs-font-size-mono-small); + line-height: var(--rs-line-height-mono-small); + letter-spacing: var(--rs-letter-spacing-mono-small); +} + +.content :not(pre) > code { + padding: 0 var(--rs-space-1); + border-radius: var(--rs-radius-1); + background: var(--rs-color-background-base-secondary); +} + +.content a { + color: var(--rs-color-foreground-accent-primary); + text-decoration: underline; + text-underline-offset: 2px; + cursor: text; +} + +.content hr { + margin-top: var(--rs-space-4); + margin-bottom: var(--rs-space-4); + border: none; + border-top: 1px solid var(--rs-color-border-base-primary); +} + +.content hr:global(.ProseMirror-selectednode) { + border-top-color: var(--rs-color-border-accent-emphasis); +} + +/* The gap cursor rules from prosemirror-gapcursor's stylesheet, which the + editor does not load. */ +.content :global(.ProseMirror-gapcursor) { display: none; + position: absolute; + pointer-events: none; } -.mentionIcon > svg, -.mentionTrailing > svg { - width: var(--rs-space-4); - height: var(--rs-space-4); +.content :global(.ProseMirror-gapcursor)::after { + content: ""; + display: block; + position: absolute; + top: -2px; + width: var(--rs-space-5); + border-top: 1px solid var(--rs-color-foreground-base-primary); } -/* NodeSelection: clicking a chip selects it as one unit. */ -.mention[data-selected] { - outline: var(--rs-focus-ring); - outline-offset: 1px; +.content:global(.ProseMirror-focused) :global(.ProseMirror-gapcursor) { + display: block; } -.editor[data-disabled] .mention { - opacity: 0.7; +.drop-cursor { + background: var(--rs-color-border-accent-emphasis); } -/* Suggestion menu. Positioned at the caret but sized to the composer: a caret - is a zero-width anchor, so `--anchor-width` is useless and the width has to - be stated. The guards keep it usable in narrow panels and wide layouts. */ -.suggestionMenu { - box-sizing: border-box; - width: var(--suggestion-menu-width, 20rem); - min-width: 240px; - max-width: min(var(--available-width), 420px); - max-height: 320px; - overflow-y: auto; - padding: var(--rs-space-2); +/* The typed `/query` reads as a pending command while the menu is open. */ +.slash-query { + border-radius: var(--rs-radius-1); + box-shadow: inset 0 0 0 1px var(--rs-color-border-base-primary); } -.suggestionGroup + .suggestionGroup { - margin-top: var(--rs-space-2); +/* ---- Toolbars ---- */ + +.toolbar { + flex-wrap: wrap; } -.suggestionGroupLabel { - padding: var(--rs-space-2) var(--rs-space-3); - color: var(--rs-color-foreground-base-tertiary); - font-size: var(--rs-font-size-mini); - line-height: var(--rs-line-height-mini); - letter-spacing: var(--rs-letter-spacing-mini); - font-weight: var(--rs-font-weight-medium); +/* A pressed toggle is filled, like a pressed Toggle. */ +.control[aria-pressed="true"] { + background-color: var(--rs-color-background-neutral-secondary); } -.suggestionRow { - cursor: pointer; +.control[aria-pressed="true"]:hover { + background-color: var(--rs-color-background-neutral-secondary-hover); +} + +.floating-popup { + min-width: 0; + max-width: none; + padding: 0; +} + +.floating-toolbar { + border: none; +} + +.tooltip { + display: inline-flex; + align-items: center; + gap: var(--rs-space-3); +} + +.shortcut-keys { + flex-shrink: 0; +} + +.menu { + min-width: 200px; } -.suggestionEmpty { - padding: var(--rs-space-3); +.menu-chevron { + width: var(--rs-space-3); + height: var(--rs-space-3); + margin-left: calc(var(--rs-space-1) * -1); color: var(--rs-color-foreground-base-tertiary); } -.suggestionLoadingRow { - padding: var(--rs-space-3); +.menu-trailing { + display: inline-flex; + align-items: center; + gap: var(--rs-space-3); +} + +.menu-check { + visibility: hidden; +} + +.menu-check[data-visible] { + visibility: visible; +} + +/* ---- Link ---- */ + +.link-popover { + padding: var(--rs-space-1); + max-width: none; +} + +.link-form { + display: flex; + align-items: center; + gap: var(--rs-space-1); + padding: var(--rs-space-1); + min-width: 280px; +} + +.link-form > :first-child { + flex: 1; } diff --git a/packages/raystack/components/editor/editor.tsx b/packages/raystack/components/editor/editor.tsx new file mode 100644 index 000000000..b3880859d --- /dev/null +++ b/packages/raystack/components/editor/editor.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { EditorBlockButton } from './editor-block-button'; +import { EditorContent } from './editor-content'; +import { EditorFloatingToolbar } from './editor-floating-toolbar'; +import { EditorHeadingMenu } from './editor-heading-menu'; +import { EditorHistoryButton } from './editor-history-button'; +import { EditorLinkButton } from './editor-link-button'; +import { EditorListMenu } from './editor-list-menu'; +import { EditorMarkButton } from './editor-mark-button'; +import { EditorMentions } from './editor-mentions'; +import { EditorRoot } from './editor-root'; +import { EditorSlashMenu } from './editor-slash-menu'; +import { EditorToolbar } from './editor-toolbar'; + +export const Editor = Object.assign(EditorRoot, { + Content: EditorContent, + Toolbar: EditorToolbar, + FloatingToolbar: EditorFloatingToolbar, + MarkButton: EditorMarkButton, + BlockButton: EditorBlockButton, + HeadingMenu: EditorHeadingMenu, + ListMenu: EditorListMenu, + LinkButton: EditorLinkButton, + HistoryButton: EditorHistoryButton, + SlashMenu: EditorSlashMenu, + Mentions: EditorMentions +}); diff --git a/packages/raystack/components/editor/index.ts b/packages/raystack/components/editor/index.ts deleted file mode 100644 index 4e9491cf2..000000000 --- a/packages/raystack/components/editor/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -export { default as editorStyles } from './editor.module.css'; -export { - deriveDocDetails, - docFromMarkup, - docFromText, - type EditorDocDetails, - isDocEmpty, - serializeMarkup, - serializeText -} from './markup'; -// ProseMirror-free, so `PromptInput`'s root, textarea and mention registry can -// import straight from here without dragging the engine into their graph. -export { - type EditorMention, - isTriggerCharacter, - type MentionAttrs, - mentionKey, - serializeMention, - trimDetails -} from './mention'; -export type { MentionPortal } from './mention-node-view'; -export { editorSchema } from './schema'; -export { - type SuggestionAnchor, - type SuggestionGroup, - SuggestionMenu, - type SuggestionMenuItem, - type SuggestionMenuProps, - suggestionOptionId -} from './suggestion-menu'; -export { - dismissSuggestion, - insertMention, - type SuggestionState -} from './suggestion-plugin'; -export { - type EditorActions, - type UseEditorOptions, - type UseEditorResult, - useEditor -} from './use-editor'; diff --git a/packages/raystack/components/editor/index.tsx b/packages/raystack/components/editor/index.tsx new file mode 100644 index 000000000..0836a9171 --- /dev/null +++ b/packages/raystack/components/editor/index.tsx @@ -0,0 +1,42 @@ +export { type EditorBlock, editorCommands } from './core/commands'; +export type { EditorJSON } from './core/json'; +export type { EditorMention } from './core/mention'; +export type { + EditorMentionItem, + EditorMentionRef +} from './core/mention-registry'; +export type { + EditorFormat, + EditorHeadingLevel, + EditorList, + EditorMark +} from './core/schema'; +export { editorToHTML, editorToText } from './core/serializers'; +export { + defaultShortcuts, + type EditorAction, + type EditorShortcuts +} from './core/shortcuts'; +export { Editor } from './editor'; +export type { EditorBlockButtonProps } from './editor-block-button'; +export type { EditorContentProps } from './editor-content'; +export { useEditor, useEditorState } from './editor-context'; +export type { EditorFloatingToolbarProps } from './editor-floating-toolbar'; +export type { EditorHeadingMenuProps } from './editor-heading-menu'; +export type { EditorHistoryButtonProps } from './editor-history-button'; +export type { EditorLinkButtonProps } from './editor-link-button'; +export type { EditorListMenuProps } from './editor-list-menu'; +export type { EditorMarkButtonProps } from './editor-mark-button'; +export type { EditorMentionsProps } from './editor-mentions'; +export type { EditorProps } from './editor-root'; +export { defaultSlashItems } from './editor-slash-items'; +export type { EditorSlashMenuProps } from './editor-slash-menu'; +export type { EditorToolbarProps } from './editor-toolbar'; +export type { + EditorApi, + EditorChangeDetails, + EditorCommands, + EditorMarkdownChangeDetails, + EditorSlashItem +} from './editor-types'; +export { MarkdownAdapter, type MarkdownAdapterOptions } from './markdown'; diff --git a/packages/raystack/components/editor/markdown/from-markdown.ts b/packages/raystack/components/editor/markdown/from-markdown.ts new file mode 100644 index 000000000..b65ce0bed --- /dev/null +++ b/packages/raystack/components/editor/markdown/from-markdown.ts @@ -0,0 +1,330 @@ +import { + Marked, + type Token, + type TokenizerExtension, + type Tokens +} from 'marked'; +import type { EditorJSON } from '../core/json'; +import { isSafeHref } from '../core/link'; +import { type MentionAttrs, readMention } from '../core/mention'; + +type JSONMark = NonNullable[number]; + +interface MentionToken extends Tokens.Generic { + type: 'mention'; + attrs: MentionAttrs; +} + +/** A trigger character, then `[`. `!` is left out because `![` opens an image. */ +const MENTION_START = /["-/:-@^`{-~]\[/; + +/** + * A `type:id` that is a link, such as `https://x` or `mailto:a@b.c`, so + * `([docs](https://x))` is a link after `(`, not a mention. + */ +function isLink(attrs: MentionAttrs): boolean { + return /^(?:https?|mailto)$/i.test(attrs.type) || attrs.id.startsWith('//'); +} + +const mentionExtension: TokenizerExtension = { + name: 'mention', + level: 'inline', + start: source => { + const index = source.search(MENTION_START); + return index === -1 ? undefined : index; + }, + tokenizer: source => { + if (source[0] === '!') return undefined; + const match = readMention(source, 0); + if (!match || isLink(match.attrs)) return undefined; + const token: MentionToken = { + type: 'mention', + raw: source.slice(0, match.next), + attrs: match.attrs + }; + return token; + } +}; + +let parser: Marked | undefined; + +function lexer(): Marked { + parser ??= new Marked({ + gfm: true, + extensions: [mentionExtension] + }); + return parser; +} + +const ENTITIES: Record = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: ' ' +}; + +/** + * Decodes character references as CommonMark does. A reference with more + * digits than CommonMark allows stays literal, and an invalid code point + * becomes U+FFFD. + */ +function decodeEntities(text: string): string { + return text.replace( + /&(#x[\da-f]{1,6}|#\d{1,7}|[a-z]+);/gi, + (match, entity: string) => { + if (entity[0] !== '#') return ENTITIES[entity.toLowerCase()] ?? match; + const code = + entity[1] === 'x' || entity[1] === 'X' + ? Number.parseInt(entity.slice(2), 16) + : Number.parseInt(entity.slice(1), 10); + const invalid = + code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff); + return invalid ? '�' : String.fromCodePoint(code); + } + ); +} + +function withMark(marks: JSONMark[], mark: JSONMark): JSONMark[] { + return marks.some(existing => existing.type === mark.type) + ? marks + : [...marks, mark]; +} + +/** Text with its marks. A soft line break becomes a hard break. */ +function textNodes(text: string, marks: JSONMark[]): EditorJSON[] { + const nodes: EditorJSON[] = []; + text.split('\n').forEach((line, index) => { + if (index > 0) nodes.push({ type: 'hardBreak' }); + if (line) { + nodes.push({ + type: 'text', + text: line, + ...(marks.length ? { marks } : {}) + }); + } + }); + return nodes; +} + +function inline(tokens: Token[] | undefined, marks: JSONMark[]): EditorJSON[] { + if (!tokens) return []; + const nodes: EditorJSON[] = []; + let underline = 0; + + for (const token of tokens) { + const current = + underline > 0 ? withMark(marks, { type: 'underline' }) : marks; + switch (token.type) { + case 'text': { + const text = token as Tokens.Text; + if (text.tokens?.length) nodes.push(...inline(text.tokens, current)); + else nodes.push(...textNodes(decodeEntities(text.text), current)); + break; + } + case 'escape': + nodes.push(...textNodes((token as Tokens.Escape).text, current)); + break; + case 'strong': + nodes.push( + ...inline( + (token as Tokens.Strong).tokens, + withMark(current, { type: 'bold' }) + ) + ); + break; + case 'em': + nodes.push( + ...inline( + (token as Tokens.Em).tokens, + withMark(current, { type: 'italic' }) + ) + ); + break; + case 'del': + nodes.push( + ...inline( + (token as Tokens.Del).tokens, + withMark(current, { type: 'strike' }) + ) + ); + break; + case 'codespan': + // Code is literal, so `<` stays as typed. + nodes.push( + ...textNodes( + (token as Tokens.Codespan).text, + withMark(current, { type: 'code' }) + ) + ); + break; + case 'link': { + const link = token as Tokens.Link; + const next = isSafeHref(link.href) + ? withMark(current, { type: 'link', attrs: { href: link.href } }) + : current; + nodes.push(...inline(link.tokens, next)); + break; + } + case 'br': + nodes.push({ type: 'hardBreak' }); + break; + case 'image': + nodes.push(...textNodes((token as Tokens.Image).text, current)); + break; + case 'mention': + nodes.push({ + type: 'mention', + attrs: { ...(token as MentionToken).attrs } + }); + break; + case 'html': { + // `` is the only HTML read as formatting. Other HTML stays text + // and is never rendered. + const html = (token as Tokens.HTML).text.trim().toLowerCase(); + if (html === '') underline += 1; + else if (html === '') underline = Math.max(0, underline - 1); + else nodes.push(...textNodes((token as Tokens.HTML).text, current)); + break; + } + default: + if ('text' in token && typeof token.text === 'string') { + nodes.push(...textNodes(token.text, current)); + } + } + } + return nodes; +} + +function paragraph(content: EditorJSON[]): EditorJSON { + return content.length + ? { type: 'paragraph', content } + : { type: 'paragraph' }; +} + +/** List item content has to start with a paragraph. */ +function itemContent(tokens: Token[]): EditorJSON[] { + const content = blocks(tokens.filter(token => token.type !== 'checkbox')); + if (content[0]?.type !== 'paragraph') content.unshift(paragraph([])); + return content; +} + +/** `- [ ]` with nothing after it. marked reads it as the text `[ ]`. */ +const EMPTY_TASK = /^\[([ xX])\]$/; + +function taskItem(item: Tokens.ListItem): EditorJSON { + const empty = item.task ? null : item.text.trim().match(EMPTY_TASK); + if (empty) { + return { + type: 'taskItem', + attrs: { checked: empty[1] !== ' ' }, + content: [paragraph([])] + }; + } + return { + type: 'taskItem', + attrs: { checked: item.checked === true }, + content: itemContent(item.tokens) + }; +} + +function list(token: Tokens.List): EditorJSON { + const tasks = token.items.some( + item => item.task || EMPTY_TASK.test(item.text.trim()) + ); + if (tasks) { + return { type: 'taskList', content: token.items.map(taskItem) }; + } + const items = token.items.map(item => ({ + type: 'listItem', + content: itemContent(item.tokens) + })); + if (!token.ordered) return { type: 'bulletList', content: items }; + const start = typeof token.start === 'number' ? token.start : 1; + return { type: 'orderedList', attrs: { start }, content: items }; +} + +function blocks(tokens: Token[]): EditorJSON[] { + const nodes: EditorJSON[] = []; + for (const token of tokens) { + switch (token.type) { + case 'space': + case 'def': + break; + case 'heading': { + const heading = token as Tokens.Heading; + const content = inline(heading.tokens, []); + nodes.push({ + type: 'heading', + attrs: { level: Math.min(heading.depth, 4) }, + ...(content.length ? { content } : {}) + }); + break; + } + case 'paragraph': + nodes.push(paragraph(inline((token as Tokens.Paragraph).tokens, []))); + break; + case 'text': { + const text = token as Tokens.Text; + nodes.push( + paragraph( + text.tokens + ? inline(text.tokens, []) + : textNodes(decodeEntities(text.text), []) + ) + ); + break; + } + case 'code': { + const code = token as Tokens.Code; + nodes.push({ + type: 'codeBlock', + attrs: { language: code.lang?.split(/\s/)[0] || null }, + ...(code.text ? { content: [{ type: 'text', text: code.text }] } : {}) + }); + break; + } + case 'hr': + nodes.push({ type: 'horizontalRule' }); + break; + case 'blockquote': { + const content = blocks((token as Tokens.Blockquote).tokens); + nodes.push({ + type: 'blockquote', + content: content.length ? content : [paragraph([])] + }); + break; + } + case 'list': + nodes.push(list(token as Tokens.List)); + break; + case 'table': { + const table = token as Tokens.Table; + const rows = [table.header, ...table.rows]; + for (const row of rows) { + nodes.push( + paragraph(textNodes(row.map(cell => cell.text).join(' | '), [])) + ); + } + break; + } + case 'html': + nodes.push( + paragraph(textNodes((token as Tokens.HTML).text.trim(), [])) + ); + break; + default: + if ('text' in token && typeof token.text === 'string') { + nodes.push(paragraph(textNodes(token.text, []))); + } + } + } + return nodes; +} + +/** Parses Markdown into editor JSON. */ +export function fromMarkdown(markdown: string): EditorJSON { + const content = blocks(lexer().lexer(markdown)); + return { type: 'doc', content: content.length ? content : [paragraph([])] }; +} diff --git a/packages/raystack/components/editor/markdown/index.ts b/packages/raystack/components/editor/markdown/index.ts new file mode 100644 index 000000000..063bec209 --- /dev/null +++ b/packages/raystack/components/editor/markdown/index.ts @@ -0,0 +1,53 @@ +import type { MarkdownAdapter as MarkdownAdapterShape } from '../editor-types'; +import { fromMarkdown } from './from-markdown'; +import { toMarkdown } from './to-markdown'; + +export interface MarkdownAdapterOptions { + /** + * `'markup'` writes `@[label](type:id)`, which round-trips. `'label'` writes `@label`. + * @default 'markup' + */ + mentions?: 'markup' | 'label'; + /** + * `'html'` writes `…`. `'drop'` writes plain text. + * @default 'html' + */ + underline?: 'html' | 'drop'; + /** + * Parses plain-text Markdown on paste. + * @default true + */ + paste?: boolean; + /** + * Writes Markdown to `text/plain` on copy. + * @default false + */ + copy?: boolean; +} + +export type MarkdownAdapter = MarkdownAdapterShape; + +function create(options: MarkdownAdapterOptions = {}): MarkdownAdapterShape { + const writeOptions = { + mentions: options.mentions ?? 'markup', + underline: options.underline ?? 'html' + }; + return { + toEditor: markdown => fromMarkdown(markdown), + fromEditor: value => toMarkdown(value, writeOptions), + paste: options.paste ?? true, + copy: options.copy ?? false + }; +} + +const defaults = create(); + +/** + * Converts between Markdown and editor JSON. Pass `MarkdownAdapter.create()` + * to the editor's `markdown` prop. + */ +export const MarkdownAdapter = { + create, + toEditor: defaults.toEditor, + fromEditor: defaults.fromEditor +}; diff --git a/packages/raystack/components/editor/markdown/to-markdown.ts b/packages/raystack/components/editor/markdown/to-markdown.ts new file mode 100644 index 000000000..b90682ecb --- /dev/null +++ b/packages/raystack/components/editor/markdown/to-markdown.ts @@ -0,0 +1,446 @@ +// The escaping, mark-boundary and list rules follow prosemirror-markdown's +// to_markdown.ts (MIT, https://github.com/ProseMirror/prosemirror-markdown), +// rewritten to walk editor JSON instead of a ProseMirror document. + +import type { EditorJSON } from '../core/json'; +import { isSafeHref, stripControlCharacters } from '../core/link'; +import { + type MentionAttrs, + mentionText, + serializeMention +} from '../core/mention'; +import { LIST_TYPES } from '../core/schema'; + +type JSONMark = NonNullable[number]; + +export interface ToMarkdownOptions { + mentions: 'markup' | 'label'; + underline: 'html' | 'drop'; +} + +/** Marks earlier in the list open first and close last. Code is innermost. */ +const MARK_ORDER = ['link', 'bold', 'italic', 'strike', 'underline', 'code']; + +const LISTS: readonly string[] = LIST_TYPES; + +const PUNCTUATION = /[\p{P}\p{S}]/u; + +function rank(mark: JSONMark): number { + const index = MARK_ORDER.indexOf(mark.type); + return index === -1 ? MARK_ORDER.length : index; +} + +function sameMark(a: JSONMark, b: JSONMark): boolean { + return a.type === b.type && a.attrs?.href === b.attrs?.href; +} + +function hrefOf(mark: JSONMark): string { + return String(mark.attrs?.href ?? ''); +} + +function firstChar(text: string): string { + const code = text.codePointAt(0); + return code === undefined ? '' : String.fromCodePoint(code); +} + +function lastChar(text: string): string { + const code = text.charCodeAt(text.length - 1); + const surrogate = code >= 0xdc00 && code <= 0xdfff && text.length > 1; + return text.slice(surrogate ? -2 : -1); +} + +function isPunctuation(char: string): boolean { + return PUNCTUATION.test(char); +} + +/** Neither whitespace nor punctuation, the characters that stop a delimiter. */ +function isWordChar(char: string): boolean { + return char !== '' && !/\s/u.test(char) && !isPunctuation(char); +} + +/** A character reference. It renders as `char`, but it counts as punctuation next to a delimiter. */ +function charRef(char: string): string { + return `&#x${(char.codePointAt(0) ?? 0).toString(16).toUpperCase()};`; +} + +function escapeText(text: string): string { + return text.replace(/[`*\\~[\]_<]/g, '\\$&').replace(/&(?=#?\w+;)/g, '\\&'); +} + +/** + * Escapes text at the start of a line. Leading whitespace would indent the + * line into a code block or be dropped, so the first space becomes a + * reference. `-`, `+`, `>`, `#`, `1.` and a run of `=` would start a list, a + * quote, a heading or a rule. + */ +function escapeLineStart(text: string): string { + if (/^[ \t]/.test(text)) return charRef(text[0]) + text.slice(1); + return text + .replace(/^[->]/, '\\$&') + .replace(/^\+(?=[ \t]|$)/, '\\+') + .replace(/^#{1,6}(?=[ \t]|$)/, '\\$&') + .replace(/^(\d{1,9})([.)])(?=[ \t]|$)/, '$1\\$2') + .replace(/^=+[ \t]*$/, '\\$&'); +} + +function escapeHref(href: string): string { + return href.replace(/[\s()<>]/g, char => encodeURIComponent(char)); +} + +function codeSpan(text: string): string { + const runs = text.match(/`+/g) ?? []; + const longest = runs.reduce((max, run) => Math.max(max, run.length), 0); + const fence = '`'.repeat(longest + 1); + const pad = text.startsWith('`') || text.endsWith('`') ? ' ' : ''; + return `${fence}${pad}${text}${pad}${fence}`; +} + +class InlineWriter { + private out = ''; + private active: JSONMark[] = []; + private trailing = ''; + private atLineStart = true; + /** + * The last closing delimiter follows punctuation, so it closes only before + * whitespace, punctuation or the end, as in `**Note:**`. + */ + private closedAfterPunctuation = false; + + constructor( + private readonly options: ToMarkdownOptions, + private readonly heading: boolean + ) {} + + private opener(mark: JSONMark): string { + switch (mark.type) { + case 'bold': + return '**'; + case 'italic': + return '*'; + case 'strike': + return '~~'; + case 'underline': + return this.options.underline === 'html' ? '' : ''; + case 'link': + return '['; + default: + return ''; + } + } + + private closer(mark: JSONMark): string { + switch (mark.type) { + case 'bold': + return '**'; + case 'italic': + return '*'; + case 'strike': + return '~~'; + case 'underline': + return this.options.underline === 'html' ? '' : ''; + case 'link': + return `](${escapeHref(stripControlCharacters(hrefOf(mark)))})`; + default: + return ''; + } + } + + /** Appends Markdown source. */ + private raw(source: string) { + if (!source) return; + let text = source; + if (this.closedAfterPunctuation) { + this.closedAfterPunctuation = false; + const first = firstChar(text); + if (isWordChar(first)) text = charRef(first) + text.slice(first.length); + } + this.out += text; + } + + /** Appends text, escaped. */ + private text(text: string) { + if (!text) return; + const escaped = escapeText(text); + this.raw(this.atLineStart ? escapeLineStart(escaped) : escaped); + this.atLineStart = false; + } + + /** Opens `marks`. `next` is the Markdown that follows the delimiters. */ + private open(marks: JSONMark[], next: string) { + const openers = marks.map(mark => this.opener(mark)).join(''); + if (!openers) return; + const run = openers.match(/^(?:\*+|~+)/)?.[0]; + if (run) { + // A run followed by punctuation opens only after whitespace or + // punctuation, as in `text**:note**`. + const after = firstChar(openers.slice(run.length) || next); + const before = lastChar(this.out); + if (isWordChar(before) && isPunctuation(after)) { + this.out = this.out.slice(0, -before.length) + charRef(before); + } + } + // `!` right before `[` would turn the link into an image. + if (openers.startsWith('[') && this.out.endsWith('!')) { + this.out = `${this.out.slice(0, -1)}\\!`; + } + this.raw(openers); + this.atLineStart = false; + } + + /** Closes the marks past `keep`, then writes the whitespace held back. */ + private settle(keep: number) { + let closers = ''; + while (this.active.length > keep) { + const mark = this.active.pop(); + if (mark) closers += this.closer(mark); + } + if (closers) { + this.raw(closers); + const run = closers.match(/(?:\*+|~+)$/)?.[0]; + if (run) { + const before = lastChar(this.out.slice(0, -run.length)); + this.closedAfterPunctuation = isPunctuation(before); + } + } + this.raw(this.trailing); + this.trailing = ''; + } + + private commonPrefix(marks: JSONMark[]): number { + let keep = 0; + while ( + keep < this.active.length && + keep < marks.length && + sameMark(this.active[keep], marks[keep]) + ) { + keep += 1; + } + return keep; + } + + write(node: EditorJSON, next: EditorJSON | undefined) { + let marks = (node.marks ?? []) + .filter(mark => mark.type !== 'link' || isSafeHref(hrefOf(mark))) + .sort((a, b) => rank(a) - rank(b)); + const code = marks.some(mark => mark.type === 'code'); + marks = marks.filter(mark => mark.type !== 'code'); + + if (node.type === 'hardBreak') { + // A mark that ends at the break closes before it, so no delimiter + // starts the next line. + marks = marks.filter( + mark => + next?.marks?.some(other => sameMark(other, mark)) && + (next.type !== 'text' || /\S/.test(next.text ?? '')) + ); + } else if ( + node.type === 'text' && + (node.text ?? '').trim() === '' && + !code + ) { + // Whitespace-only text opens no new marks, so no `** **` is written. + marks = marks.slice(0, this.commonPrefix(marks)); + } + + const keep = this.commonPrefix(marks); + this.settle(keep); + + if (node.type === 'text') { + this.writeText(node.text ?? '', marks, keep, code); + return; + } + + if (node.type === 'hardBreak') { + // A heading is one line, so a break in it becomes a space. + const output = this.heading ? ' ' : '\\\n'; + this.open(marks.slice(keep), output); + this.active = marks; + this.raw(output); + this.atLineStart = !this.heading; + return; + } + + if (node.type === 'mention') { + const attrs = node.attrs as unknown as MentionAttrs; + if (this.options.mentions === 'label') { + const label = mentionText(attrs); + this.open(marks.slice(keep), escapeText(label)); + this.active = marks; + this.text(label); + return; + } + // `![` opens an image, so a `!` mention reads back as text. + let markup = serializeMention(attrs); + if (markup.startsWith('![')) markup = `\\${markup}`; + this.open(marks.slice(keep), markup); + this.active = marks; + this.raw(markup); + this.atLineStart = false; + } + } + + private writeText( + text: string, + marks: JSONMark[], + keep: number, + code: boolean + ) { + const lead = text.match(/^\s*/)?.[0] ?? ''; + const trail = + text.length > lead.length ? (text.match(/\s*$/)?.[0] ?? '') : ''; + const core = text.slice(lead.length, text.length - trail.length); + + if (marks.length > keep || code) { + // Markdown does not allow whitespace just inside a delimiter, so it + // moves outside the marks. + this.text(lead); + const body = code ? (core ? codeSpan(core) : '') : escapeText(core); + this.open(marks.slice(keep), body); + this.active = marks; + this.raw(body); + if (body) this.atLineStart = false; + } else { + this.active = marks; + this.text(lead + core); + } + + if (code || marks.length) this.trailing = trail; + else this.raw(trail); + } + + finish(): string { + this.settle(0); + return this.out; + } +} + +function inline( + content: EditorJSON[] | undefined, + options: ToMarkdownOptions, + heading = false +): string { + const nodes = [...(content ?? [])]; + // A hard break at the end of a block has no Markdown form. + while (nodes[nodes.length - 1]?.type === 'hardBreak') nodes.pop(); + const writer = new InlineWriter(options, heading); + nodes.forEach((node, index) => { + writer.write(node, nodes[index + 1]); + }); + return writer.finish(); +} + +function indent(text: string, first: string, rest: string): string { + return text + .split('\n') + .map((line, index) => { + if (index === 0) return first + line; + return line ? rest + line : line; + }) + .join('\n'); +} + +function textOf(node: EditorJSON): string { + return (node.content ?? []).map(child => child.text ?? '').join(''); +} + +/** + * Blocks with `separator` before each one after the first. A list right after + * a list that uses the same marker switches its marker (`-` and `*`, `.` and + * `)`), or the two would read back as one list. + */ +function sequence( + nodes: EditorJSON[], + options: ToMarkdownOptions, + separator: (node: EditorJSON) => string +): string { + let out = ''; + let previous: string | null = null; + nodes.forEach((node, index) => { + if (index > 0) out += separator(node); + if (!LISTS.includes(node.type)) { + out += block(node, options); + previous = null; + return; + } + const ordered = node.type === 'orderedList'; + const [first, second] = ordered ? ['.', ')'] : ['-', '*']; + const marker = previous === first ? second : first; + out += list(node, options, marker); + previous = marker; + }); + return out; +} + +function listItem(item: EditorJSON, options: ToMarkdownOptions): string { + return sequence(item.content ?? [], options, child => + LISTS.includes(child.type) ? '\n' : '\n\n' + ); +} + +function list( + node: EditorJSON, + options: ToMarkdownOptions, + marker: string +): string { + const items = node.content ?? []; + // A list with a second paragraph or a code block in an item is loose. + const loose = items.some(item => + (item.content ?? []).slice(1).some(child => !LISTS.includes(child.type)) + ); + const start = Number(node.attrs?.start ?? 1); + const first = Number.isSafeInteger(start) && start >= 0 ? start : 1; + return items + .map((item, index) => { + let prefix = `${marker} `; + if (node.type === 'orderedList') prefix = `${first + index}${marker} `; + if (node.type === 'taskList') { + prefix = `${marker} [${item.attrs?.checked ? 'x' : ' '}] `; + } + const body = listItem(item, options); + return indent(body, prefix, ' '.repeat(prefix.length)); + }) + .join(loose ? '\n\n' : '\n'); +} + +function block(node: EditorJSON, options: ToMarkdownOptions): string { + switch (node.type) { + case 'paragraph': + return inline(node.content, options); + case 'heading': { + const level = Math.min( + Math.max(Math.round(Number(node.attrs?.level)) || 1, 1), + 6 + ); + return `${'#'.repeat(level)} ${inline(node.content, options, true)}`; + } + case 'blockquote': + return blocks(node.content, options) + .split('\n') + .map(line => (line ? `> ${line}` : '>')) + .join('\n'); + case 'codeBlock': { + const text = textOf(node); + const runs = text.match(/`{3,}/g) ?? []; + const longest = runs.reduce((max, run) => Math.max(max, run.length), 2); + const fence = '`'.repeat(longest + 1); + const language = (node.attrs?.language as string | null) ?? ''; + return `${fence}${language}\n${text}\n${fence}`; + } + case 'horizontalRule': + return '---'; + default: + return node.content ? blocks(node.content, options) : (node.text ?? ''); + } +} + +function blocks( + content: EditorJSON[] | undefined, + options: ToMarkdownOptions +): string { + return sequence(content ?? [], options, () => '\n\n'); +} + +/** Serializes editor JSON as Markdown. */ +export function toMarkdown(value: EditorJSON, options: ToMarkdownOptions) { + return blocks(value.content, options); +} diff --git a/packages/raystack/components/editor/schema.ts b/packages/raystack/components/editor/schema.ts deleted file mode 100644 index 1f785cf7a..000000000 --- a/packages/raystack/components/editor/schema.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Schema } from 'prosemirror-model'; -import type { MentionAttrs } from './mention'; - -/** - * A deliberately tiny schema: one paragraph of text, hard breaks, and atomic - * mentions. Nothing else, so pasted HTML sanitizes to plain text for free, - * there is no mark or block the parser could keep. - */ -export const editorSchema = new Schema({ - nodes: { - doc: { content: 'paragraph' }, - - paragraph: { - content: 'inline*', - parseDOM: [{ tag: 'p' }], - toDOM: () => ['p', 0] - }, - - text: { group: 'inline' }, - - hardBreak: { - inline: true, - group: 'inline', - selectable: false, - parseDOM: [{ tag: 'br' }], - toDOM: () => ['br'] - }, - - mention: { - inline: true, - group: 'inline', - // Atomic: the cursor never enters it, so it deletes and moves as one unit. - atom: true, - selectable: true, - // ProseMirror makes inline atoms draggable by default, which would let a - // chip be dropped into the middle of a word. - draggable: false, - attrs: { - id: {}, - label: {}, - type: { default: 'mention' }, - trigger: { default: '@' } - }, - parseDOM: [ - { - tag: 'span[data-mention-id]', - getAttrs: dom => { - const el = dom as HTMLElement; - return { - id: el.getAttribute('data-mention-id') ?? '', - label: el.getAttribute('data-mention-label') ?? el.textContent, - type: el.getAttribute('data-mention-type') ?? 'mention', - trigger: el.getAttribute('data-mention-trigger') ?? '@' - }; - } - } - ], - // Only used for the clipboard's `text/html` flavour. On screen the node - // view owns the element. Pasting this back restores the chip with its id. - toDOM: node => { - const { id, label, type, trigger } = node.attrs as MentionAttrs; - return [ - 'span', - { - 'data-mention-id': id, - 'data-mention-label': label, - 'data-mention-type': type, - 'data-mention-trigger': trigger - }, - `${trigger}${label}` - ]; - } - } - } -}); - -export const mentionType = editorSchema.nodes.mention; -export const hardBreakType = editorSchema.nodes.hardBreak; -export const paragraphType = editorSchema.nodes.paragraph; diff --git a/packages/raystack/components/editor/use-editor-suggestion.ts b/packages/raystack/components/editor/use-editor-suggestion.ts new file mode 100644 index 000000000..1c99a336f --- /dev/null +++ b/packages/raystack/components/editor/use-editor-suggestion.ts @@ -0,0 +1,78 @@ +'use client'; + +import { useId, useLayoutEffect, useRef } from 'react'; +import type { SuggestionState } from './core/suggestion-plugin'; +import { + type SuggestionItem, + type UseSuggestionMenuOptions, + type UseSuggestionMenuResult, + useSuggestionMenu +} from './core/use-suggestion-menu'; +import { useStoreSelector } from './editor-context'; +import type { EditorStore } from './editor-store'; + +interface UseEditorSuggestionOptions + extends Omit< + UseSuggestionMenuOptions, + 'getView' | 'suggestion' | 'open' | 'dismiss' | 'listboxId' + > { + store: EditorStore; + trigger: string; + maxSpaces?: number; + className?: string; +} + +/** + * Registers a trigger with the editor and drives its menu. The editor routes + * keys to the menu while its query is active. + */ +export function useEditorSuggestion({ + store, + trigger, + maxSpaces, + className, + ...options +}: UseEditorSuggestionOptions): { + menu: UseSuggestionMenuResult; + listboxId: string; +} { + const listboxId = useId(); + const keyDownRef = useRef< + ((event: KeyboardEvent, state: SuggestionState) => boolean) | null + >(null); + + useLayoutEffect( + () => + store.registerTrigger({ + char: trigger, + maxSpaces, + className, + onKeyDown: (event, state) => keyDownRef.current?.(event, state) ?? false + }), + [store, trigger, maxSpaces, className] + ); + + const suggestion = useStoreSelector(store, current => + current.suggestion?.trigger === trigger ? current.suggestion : null + ); + const editable = useStoreSelector(store, current => current.isEditable()); + + const menu = useSuggestionMenu({ + ...options, + getView: () => store.view, + suggestion, + open: editable && suggestion !== null, + dismiss: store.dismissSuggestion, + listboxId + }); + keyDownRef.current = menu.handleKeyDown; + + useLayoutEffect(() => { + store.setMenu( + trigger, + menu.open ? { listboxId, activeOptionId: menu.activeOptionId } : null + ); + }, [store, trigger, listboxId, menu.open, menu.activeOptionId]); + + return { menu, listboxId }; +} diff --git a/packages/raystack/components/editor/__tests__/markup.test.ts b/packages/raystack/components/prompt-input/__tests__/prompt-input-markup.test.ts similarity index 97% rename from packages/raystack/components/editor/__tests__/markup.test.ts rename to packages/raystack/components/prompt-input/__tests__/prompt-input-markup.test.ts index eaf5076cb..0caeb7a9d 100644 --- a/packages/raystack/components/editor/__tests__/markup.test.ts +++ b/packages/raystack/components/prompt-input/__tests__/prompt-input-markup.test.ts @@ -6,8 +6,12 @@ import { isDocEmpty, serializeMarkup, serializeText -} from '../markup'; -import { isTriggerCharacter, serializeMention, trimDetails } from '../mention'; +} from '../../editor/core/composer/markup'; +import { + isTriggerCharacter, + serializeMention, + trimDetails +} from '../../editor/core/mention'; const derive = (markup: string) => deriveDocDetails(docFromMarkup(markup)); const roundTrip = (markup: string) => serializeMarkup(docFromMarkup(markup)); diff --git a/packages/raystack/components/prompt-input/prompt-input-context.tsx b/packages/raystack/components/prompt-input/prompt-input-context.tsx index cd77097ca..15e160a3a 100644 --- a/packages/raystack/components/prompt-input/prompt-input-context.tsx +++ b/packages/raystack/components/prompt-input/prompt-input-context.tsx @@ -1,7 +1,7 @@ 'use client'; import { createContext, type RefObject, useContext } from 'react'; -import type { EditorMention } from '../editor/mention'; +import type { EditorMention } from '../editor/core/mention'; import type { PromptInputMentionItem, PromptInputMentionRegistry diff --git a/packages/raystack/components/prompt-input/prompt-input-editor.tsx b/packages/raystack/components/prompt-input/prompt-input-editor.tsx index 9b09399f1..42e83894b 100644 --- a/packages/raystack/components/prompt-input/prompt-input-editor.tsx +++ b/packages/raystack/components/prompt-input/prompt-input-editor.tsx @@ -15,18 +15,19 @@ import { import { createPortal } from 'react-dom'; import { deriveDocDetails, - docFromMarkup, - editorStyles, - SuggestionMenu, - type SuggestionState, - useEditor -} from '../editor'; + docFromMarkup +} from '../editor/core/composer/markup'; +import { useComposerEditor } from '../editor/core/composer/use-composer-editor'; +import editorStyles from '../editor/core/editor-core.module.css'; +import { SuggestionMenu } from '../editor/core/suggestion-menu'; +import type { SuggestionState } from '../editor/core/suggestion-plugin'; +import { useMentionResolution } from '../editor/core/use-suggestion-menu'; import styles from './prompt-input.module.css'; import { type PromptInputInputApi, usePromptInputContext } from './prompt-input-context'; -import { useMentionMenu, useMentionResolution } from './use-mention-menu'; +import { useMentionMenu } from './use-mention-menu'; export interface PromptInputEditorProps extends Omit< @@ -85,23 +86,24 @@ export function PromptInputEditor({ ((event: KeyboardEvent, state: SuggestionState) => boolean) | null >(null); - const { hostRef, initialHtml, viewRef, mentionPortals, actions } = useEditor({ - initialMarkup: context.value, - placeholder, - disabled: resolvedDisabled, - spellCheck, - maxLength, - getTriggers: () => registry.triggers(), - onChange: details => - setValueRef.current(details.markup, { - text: details.text, - mentions: details.mentions - }), - onSubmit: () => requestSubmitRef.current(), - onSuggestionChange: setSuggestion, - onSuggestionKeyDown: (event, state) => - keyDownRef.current?.(event, state) ?? false - }); + const { hostRef, initialHtml, viewRef, mentionPortals, actions } = + useComposerEditor({ + initialMarkup: context.value, + placeholder, + disabled: resolvedDisabled, + spellCheck, + maxLength, + getTriggers: () => registry.triggers(), + onChange: details => + setValueRef.current(details.markup, { + text: details.text, + mentions: details.mentions + }), + onSubmit: () => requestSubmitRef.current(), + onSuggestionChange: setSuggestion, + onSuggestionKeyDown: (event, state) => + keyDownRef.current?.(event, state) ?? false + }); const menu = useMentionMenu({ viewRef, @@ -113,7 +115,11 @@ export function PromptInputEditor({ }); keyDownRef.current = menu.handleKeyDown; - useMentionResolution(registry, context.details.mentions, actions); + useMentionResolution( + registry, + context.details.mentions, + actions.refreshMentionLabels + ); const api = useMemo( () => ({ @@ -184,7 +190,12 @@ export function PromptInputEditor({
Promise; - resolveMentions?: ( - refs: PromptInputMentionRef[] - ) => Promise; - onOpenChange?: (open: boolean) => void; - emptyMessage?: ReactNode; - loadingRowCount?: number; -} - -export interface PromptInputMentionsConfig extends PromptInputMentionsData { - trigger: string; -} - -function sameData( - a: PromptInputMentionsData, - b: PromptInputMentionsData -): boolean { - return ( - a.items === b.items && - a.onSearch === b.onSearch && - a.resolveMentions === b.resolveMentions && - a.onOpenChange === b.onOpenChange && - a.emptyMessage === b.emptyMessage && - a.loadingRowCount === b.loadingRowCount - ); -} - -/** - * Shared between `Mentions` (which writes the config), `Editor` (which reads - * triggers, drives the menu and decorates chips) and Root (which reads `data` - * back when assembling a message). - * - * `icon`, `trailing` and `data` cannot survive serialization, so they live here - * rather than on the document, keyed by `trigger|type|id` and filled in when an - * item is picked from the menu or returned by `resolveMentions`. - * - * Registration is split from data on purpose. The trigger is established once, - * while the data is pushed after every `Mentions` render and compared field by - * field, so an inline `items` array stays live without a config object whose - * identity churns and restarts an in-flight search. - */ -export class PromptInputMentionRegistry { - private configs = new Map(); - private items = new Map(); - private listeners = new Set<() => void>(); - private revision = 0; - - register(trigger: string): () => void { - if (!this.configs.has(trigger)) { - this.configs.set(trigger, { trigger }); - this.emit(); - } - return () => { - if (this.configs.delete(trigger)) this.emit(); - }; - } - - setData(trigger: string, data: PromptInputMentionsData): void { - const current = this.configs.get(trigger); - if (!current || sameData(current, data)) return; - this.configs.set(trigger, { trigger, ...data }); - this.emit(); - } - - get(trigger: string): PromptInputMentionsConfig | undefined { - return this.configs.get(trigger); - } - - triggers(): string[] { - return [...this.configs.keys()]; - } - - remember(trigger: string, item: PromptInputMentionItem): void { - this.rememberAll(trigger, [item]); - } - - rememberAll(trigger: string, items: PromptInputMentionItem[]): void { - if (items.length === 0) return; - for (const item of items) { - const type = item.type ?? 'mention'; - this.items.set(mentionKey(trigger, type, item.id), { ...item, type }); - } - this.emit(); - } - - lookup( - trigger: string, - type: string, - id: string - ): PromptInputMentionItem | undefined { - return this.items.get(mentionKey(trigger, type, id)); - } - - has(trigger: string, type: string, id: string): boolean { - return this.items.has(mentionKey(trigger, type, id)); - } - - subscribe = (listener: () => void): (() => void) => { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - }; - - /** - * Bumped by every mutation. Read as a `useSyncExternalStore` snapshot, so a - * reader that mounts after a writer has already emitted still sees the - * change, since `Mentions` registers its trigger in an effect that runs before a - * later sibling `Editor` has subscribed. - */ - getRevision = (): number => this.revision; - - private emit() { - this.revision += 1; - for (const listener of this.listeners) listener(); - } -} +export { + type EditorMentionItem as PromptInputMentionItem, + type EditorMentionRef as PromptInputMentionRef, + MentionRegistry as PromptInputMentionRegistry, + type MentionsData as PromptInputMentionsData +} from '../editor/core/mention-registry'; diff --git a/packages/raystack/components/prompt-input/prompt-input-mentions.tsx b/packages/raystack/components/prompt-input/prompt-input-mentions.tsx index ff8f16059..9a0473ad3 100644 --- a/packages/raystack/components/prompt-input/prompt-input-mentions.tsx +++ b/packages/raystack/components/prompt-input/prompt-input-mentions.tsx @@ -1,7 +1,7 @@ 'use client'; import { type ReactNode, useEffect, useRef } from 'react'; -import { isTriggerCharacter } from '../editor/mention'; +import { isTriggerCharacter } from '../editor/core/mention'; import { usePromptInputContext } from './prompt-input-context'; import type { PromptInputMentionItem, diff --git a/packages/raystack/components/prompt-input/prompt-input-root.tsx b/packages/raystack/components/prompt-input/prompt-input-root.tsx index 994577d54..8e5b11fe9 100644 --- a/packages/raystack/components/prompt-input/prompt-input-root.tsx +++ b/packages/raystack/components/prompt-input/prompt-input-root.tsx @@ -16,7 +16,7 @@ import { } from 'react'; // Imported from the leaf module, not the barrel: the root must not pull the // editor engine into a composer that only ever renders `Textarea`. -import { trimDetails } from '../editor/mention'; +import { trimDetails } from '../editor/core/mention'; import styles from './prompt-input.module.css'; import { isEmptyValue, diff --git a/packages/raystack/components/prompt-input/use-mention-menu.ts b/packages/raystack/components/prompt-input/use-mention-menu.ts index 5f11c4740..ea2fb7b5c 100644 --- a/packages/raystack/components/prompt-input/use-mention-menu.ts +++ b/packages/raystack/components/prompt-input/use-mention-menu.ts @@ -1,144 +1,35 @@ 'use client'; -import { compareItems, rankItem } from '@tanstack/match-sorter-utils'; import type { EditorView } from 'prosemirror-view'; +import { useCallback } from 'react'; +import type { ComposerEditorActions } from '../editor/core/composer/use-composer-editor'; +import type { SuggestionState } from '../editor/core/suggestion-plugin'; import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore -} from 'react'; -import { - type EditorActions, - type SuggestionAnchor, - type SuggestionGroup, - type SuggestionState, - suggestionOptionId -} from '../editor'; -import type { PromptInputMention } from './prompt-input-context'; + type UseSuggestionMenuResult, + useMentionRegistryVersion, + useSuggestionMenu +} from '../editor/core/use-suggestion-menu'; import type { PromptInputMentionItem, PromptInputMentionRegistry } from './prompt-input-mention-registry'; -/** Long enough that a fast typist makes one request per word, not per letter. */ -const SEARCH_DEBOUNCE_MS = 150; - -const NO_ITEMS: PromptInputMentionItem[] = []; - -/** - * Re-renders whatever reads the registry when a config or an item lands. - * `useSyncExternalStore` rather than a subscribe-and-bump effect, because - * `Mentions` registers its trigger from an effect that runs *before* a later - * sibling `Editor` gets to subscribe, and the store re-reads its snapshot after - * subscribing, so that first registration is never missed. - */ -export function useMentionRegistryVersion( - registry: PromptInputMentionRegistry -): void { - useSyncExternalStore( - registry.subscribe, - registry.getRevision, - registry.getRevision - ); -} - -/** - * Groups render in first-appearance order of `group` within the results, so - * consumers control section order by ordering their data. Ungrouped items lead - * in a headerless section, and a group that filters down to nothing disappears. - */ -export function toGroups(items: PromptInputMentionItem[]): SuggestionGroup[] { - const ungrouped: PromptInputMentionItem[] = []; - const order: string[] = []; - const buckets = new Map(); - - for (const item of items) { - if (!item.group) { - ungrouped.push(item); - continue; - } - const bucket = buckets.get(item.group); - if (bucket) { - bucket.push(item); - } else { - buckets.set(item.group, [item]); - order.push(item.group); - } - } - - const groups: SuggestionGroup[] = []; - if (ungrouped.length) groups.push({ items: ungrouped }); - for (const label of order) { - groups.push({ label, items: buckets.get(label) ?? [] }); - } - return groups; -} - -/** Sync data filtering: match-sorter on the label, best matches first. */ -export function filterItems( - items: PromptInputMentionItem[], - query: string -): PromptInputMentionItem[] { - if (!query) return items; - const ranked = items - .map(item => ({ - item, - ranking: rankItem(item, query, { - accessors: [entry => (entry as PromptInputMentionItem).label] - }) - })) - .filter(entry => entry.ranking.passed); - ranked.sort((a, b) => compareItems(a.ranking, b.ranking)); - return ranked.map(entry => entry.item); -} - -function firstEnabled(items: PromptInputMentionItem[]): number { - const index = items.findIndex(item => !item.disabled); - return index; -} - -function step( - items: PromptInputMentionItem[], - from: number, - direction: 1 | -1 -): number { - if (items.length === 0) return -1; - let index = from; - for (let attempt = 0; attempt < items.length; attempt += 1) { - index = (index + direction + items.length) % items.length; - if (!items[index]?.disabled) return index; - } - return -1; -} - export interface UseMentionMenuOptions { viewRef: React.RefObject; - actions: EditorActions; + actions: ComposerEditorActions; registry: PromptInputMentionRegistry; suggestion: SuggestionState | null; disabled: boolean; listboxId: string; } -export interface UseMentionMenuResult { - open: boolean; - anchor: SuggestionAnchor; - groups: SuggestionGroup[]; - highlightedIndex: number; - setHighlightedIndex: (index: number) => void; - loading: boolean; +export interface UseMentionMenuResult + extends UseSuggestionMenuResult { loadingRowCount: number; emptyMessage: React.ReactNode; - select: (item: PromptInputMentionItem) => void; - close: () => void; - activeOptionId: string | undefined; - /** Routed from the ProseMirror plugin while a query is active. */ - handleKeyDown: (event: KeyboardEvent, state: SuggestionState) => boolean; } +/** One menu for every registered trigger, driven by that trigger's config. */ export function useMentionMenu({ viewRef, actions, @@ -152,130 +43,8 @@ export function useMentionMenu({ const config = suggestion ? registry.get(suggestion.trigger) : undefined; const open = !disabled && suggestion !== null && config !== undefined; - const suggestionRef = useRef(suggestion); - suggestionRef.current = suggestion; - - const [results, setResults] = useState(NO_ITEMS); - const [loading, setLoading] = useState(false); - const [highlightedIndex, setHighlightedIndex] = useState(-1); - - const trigger = suggestion?.trigger; - const query = suggestion?.query ?? ''; - // Depended on individually rather than through `config`, so a changed - // `emptyMessage` cannot restart an in-flight search. - const search = config?.onSearch; - const syncItems = config?.items; - - // Async results: debounced, aborted on supersede, and guarded by a sequence - // number so a slow response for an old query can never land. - const sequenceRef = useRef(0); - const warnedRef = useRef(false); - - useEffect(() => { - if (!open || trigger === undefined) { - setResults(NO_ITEMS); - setLoading(false); - return; - } - - if (!search) { - setLoading(false); - setResults(filterItems(syncItems ?? NO_ITEMS, query)); - return; - } - - const sequence = (sequenceRef.current += 1); - const controller = new AbortController(); - setLoading(true); - - const timer = window.setTimeout(() => { - search(query, { trigger, signal: controller.signal }) - .then(items => { - if (sequence !== sequenceRef.current) return; - setResults(items); - setLoading(false); - }) - .catch((error: unknown) => { - if (sequence !== sequenceRef.current) return; - setLoading(false); - setResults(NO_ITEMS); - const aborted = - controller.signal.aborted || - (error instanceof Error && error.name === 'AbortError'); - if ( - !aborted && - !warnedRef.current && - process.env.NODE_ENV !== 'production' - ) { - warnedRef.current = true; - console.warn( - '[Apsara] PromptInput.Mentions onSearch rejected; the menu falls ' + - 'back to its empty state.', - error - ); - } - }); - }, SEARCH_DEBOUNCE_MS); - - return () => { - window.clearTimeout(timer); - controller.abort(); - }; - }, [open, trigger, query, search, syncItems]); - - const groups = useMemo(() => toGroups(results), [results]); - const flat = useMemo(() => groups.flatMap(group => group.items), [groups]); - const flatRef = useRef(flat); - flatRef.current = flat; - - // The first enabled row is auto-highlighted whenever the result set changes. - // Keyed on what the rows *are* rather than on the array's identity: an inline - // `items={[…]}` prop is a fresh array on every consumer render, and resetting - // on identity would throw away the user's arrow-key position whenever - // something unrelated re-rendered above the composer. - const rowSignature = useMemo( - () => - flat - .map(item => `${item.type ?? ''}:${item.id}:${item.disabled ? 1 : 0}`) - .join('\0'), - [flat] - ); - // biome-ignore lint/correctness/useExhaustiveDependencies: the signature is the trigger; the rows are read through a ref - useEffect(() => { - setHighlightedIndex(firstEnabled(flatRef.current)); - }, [rowSignature]); - - // Held past the config that supplied it: closing clears the active trigger, - // so reading the callback off the live config would swallow the `false`. - const onOpenChangeRef = useRef<((open: boolean) => void) | undefined>( - undefined - ); - if (config?.onOpenChange) onOpenChangeRef.current = config.onOpenChange; - const lastOpenRef = useRef(false); - useEffect(() => { - if (lastOpenRef.current === open) return; - lastOpenRef.current = open; - onOpenChangeRef.current?.(open); - }, [open]); - - const close = useCallback(() => { - actions.dismissSuggestion(); - }, [actions]); - - // Spaces are allowed while results are non-empty, so multi-word entities stay - // filterable. The first keystroke that empties the results while the query - // already contains a space gives up and leaves the text literal. - useEffect(() => { - if (!open || loading) return; - if (results.length > 0) return; - if (!query.includes(' ')) return; - close(); - }, [open, loading, results.length, query, close]); - const select = useCallback( - (item: PromptInputMentionItem) => { - const state = suggestionRef.current; - if (!state) return; + (item: PromptInputMentionItem, state: SuggestionState) => { const type = item.type ?? 'mention'; registry.remember(state.trigger, { ...item, type }); actions.insertMention( @@ -291,177 +60,22 @@ export function useMentionMenu({ [actions, registry] ); - // The last rect the caret actually had. Selecting an item takes the query - // range out of the document in the same breath as it closes the menu, but the - // popup is still animating out and the positioner keeps measuring. Without - // something to hand back, it would read a zero rect and the closing menu - // would jump to the top-left corner of the viewport and flicker there. - const lastRectRef = useRef(null); - - const anchor = useMemo( - () => ({ - get contextElement() { - return viewRef.current?.dom; - }, - getBoundingClientRect: () => { - const view = viewRef.current; - const state = suggestionRef.current; - if (view && state) { - try { - const coords = view.coordsAtPos(state.from); - const rect = new DOMRect( - coords.left, - coords.top, - 0, - Math.max(0, coords.bottom - coords.top) - ); - lastRectRef.current = rect; - return rect; - } catch { - // The range is gone, or the browser is mid-relayout. - } - } - // jsdom has no geometry to give either, and no animation to cover. - return lastRectRef.current ?? new DOMRect(0, 0, 0, 0); - } - }), - [viewRef] - ); - - const highlightedRef = useRef(highlightedIndex); - highlightedRef.current = highlightedIndex; - const openRef = useRef(open); - openRef.current = open; - - const handleKeyDown = useCallback( - (event: KeyboardEvent) => { - if (!openRef.current) return false; - - const items = flatRef.current; - const current = highlightedRef.current; - - const consume = () => { - event.preventDefault(); - // Enter must not reach the form and Escape must not reach ChatPanel, - // Dialog or Drawer, either of which would destroy the draft. - event.stopPropagation(); - }; - - switch (event.key) { - case 'ArrowDown': - consume(); - setHighlightedIndex(step(items, current, 1)); - return true; - case 'ArrowUp': - consume(); - setHighlightedIndex(step(items, current, -1)); - return true; - case 'Escape': - consume(); - close(); - return true; - case 'Enter': - case 'Tab': { - const item = items[current]; - if (!item || item.disabled) { - // Nothing to pick: leave the text literal and let the key through - // to submit or to move focus. - close(); - return false; - } - consume(); - select(item); - return true; - } - default: - return false; - } - }, - [close, select] - ); + const menu = useSuggestionMenu({ + getView: () => viewRef.current, + suggestion, + open, + items: config?.items, + onSearch: config?.onSearch, + onSelect: select, + dismiss: actions.dismissSuggestion, + onOpenChange: config?.onOpenChange, + listboxId, + name: 'PromptInput.Mentions' + }); return { - open, - anchor, - groups, - highlightedIndex, - setHighlightedIndex, - loading, + ...menu, loadingRowCount: config?.loadingRowCount ?? 3, - emptyMessage: config?.emptyMessage ?? 'No results', - select, - close, - activeOptionId: - open && highlightedIndex >= 0 - ? suggestionOptionId(listboxId, highlightedIndex) - : undefined, - handleKeyDown + emptyMessage: config?.emptyMessage ?? 'No results' }; } - -/** - * `icon`, `trailing` and `data` cannot survive serialization, so a chip parsed - * from `defaultValue` starts label-only and fills in when the consumer's - * `resolveMentions` resolves, the same progressive enhancement `Select.Value` - * uses when it falls back to the raw value until an item registers. A rejection - * or a missing item leaves the chip label-only; it is never an error state and - * the chip is never removed. - */ -export function useMentionResolution( - registry: PromptInputMentionRegistry, - mentions: PromptInputMention[], - actions: EditorActions -): void { - const requestedRef = useRef(new Set()); - const actionsRef = useRef(actions); - actionsRef.current = actions; - - useEffect(() => { - if (mentions.length === 0) return; - - const byTrigger = new Map< - string, - Array<{ type: string; id: string; label: string }> - >(); - - for (const mention of mentions) { - const key = `${mention.trigger}|${mention.type}|${mention.id}`; - if (requestedRef.current.has(key)) continue; - if (registry.has(mention.trigger, mention.type, mention.id)) continue; - const config = registry.get(mention.trigger); - if (!config?.resolveMentions) continue; - requestedRef.current.add(key); - const bucket = byTrigger.get(mention.trigger); - const ref = { - type: mention.type, - id: mention.id, - label: mention.label - }; - if (bucket) bucket.push(ref); - else byTrigger.set(mention.trigger, [ref]); - } - - if (byTrigger.size === 0) return; - - for (const [trigger, refs] of byTrigger) { - const resolve = registry.get(trigger)?.resolveMentions; - if (!resolve) continue; - resolve(refs) - .then(items => { - if (items.length === 0) return; - registry.rememberAll(trigger, items); - const labels = new Map(); - for (const item of items) { - labels.set( - `${trigger}|${item.type ?? 'mention'}|${item.id}`, - item.label - ); - } - actionsRef.current.refreshMentionLabels(labels); - }) - .catch(() => { - // Label-only is the fallback, so there is nothing to recover. - }); - } - }, [registry, mentions]); -} diff --git a/packages/raystack/icons/icons.tsx b/packages/raystack/icons/icons.tsx index 68470038f..926ad1fb1 100644 --- a/packages/raystack/icons/icons.tsx +++ b/packages/raystack/icons/icons.tsx @@ -8,6 +8,7 @@ import { ArrowDownWideNarrow, ArrowUp, ArrowUpNarrowWide, + Bold, CalendarDays, Check, ChevronDown, @@ -15,25 +16,43 @@ import { ChevronRight, CircleCheck, CircleX, + Code, Copy, Ellipsis, Expand, + ExternalLink, FileText, + Heading1, + Heading2, + Heading3, + Heading4, Info, + Italic, + Link, + List, ListFilter, + ListOrdered, + ListTodo, Minus, Moon, PanelLeft, Plus, + Redo2, Search, Shrink, SlidersHorizontal, Sparkles, Square, + SquareCode, + Strikethrough, Sun, Table, + TextQuote, TriangleAlert, + Type, + Underline, Undo2, + Unlink, X } from 'lucide-react'; import { createIcon } from './create-icon'; @@ -43,12 +62,17 @@ export const ArrowDownIcon = /*#__PURE__*/ createIcon( ArrowDown ); export const ArrowUpIcon = /*#__PURE__*/ createIcon('ArrowUpIcon', ArrowUp); +export const BoldIcon = /*#__PURE__*/ createIcon('BoldIcon', Bold); /** Draws lucide `CalendarDays`, not lucide `Calendar`. */ export const CalendarIcon = /*#__PURE__*/ createIcon( 'CalendarIcon', CalendarDays ); export const CheckIcon = /*#__PURE__*/ createIcon('CheckIcon', Check); +export const ChecklistIcon = /*#__PURE__*/ createIcon( + 'ChecklistIcon', + ListTodo +); export const ChevronDownIcon = /*#__PURE__*/ createIcon( 'ChevronDownIcon', ChevronDown @@ -65,6 +89,11 @@ export const ChevronRightIcon = /*#__PURE__*/ createIcon( export const ClearIcon = /*#__PURE__*/ createIcon('ClearIcon', CircleX); /** Marks an AI affordance. The ChatPanel trigger draws it. */ export const CoPilotIcon = /*#__PURE__*/ createIcon('CoPilotIcon', Sparkles); +export const CodeBlockIcon = /*#__PURE__*/ createIcon( + 'CodeBlockIcon', + SquareCode +); +export const CodeIcon = /*#__PURE__*/ createIcon('CodeIcon', Code); export const CopyIcon = /*#__PURE__*/ createIcon('CopyIcon', Copy); export const DisplayIcon = /*#__PURE__*/ createIcon( 'DisplayIcon', @@ -74,16 +103,33 @@ export const EllipsisIcon = /*#__PURE__*/ createIcon('EllipsisIcon', Ellipsis); /** The error status of a Toast. Shares a drawing with `ClearIcon`. */ export const ErrorIcon = /*#__PURE__*/ createIcon('ErrorIcon', CircleX); export const ExpandIcon = /*#__PURE__*/ createIcon('ExpandIcon', Expand); +export const ExternalLinkIcon = /*#__PURE__*/ createIcon( + 'ExternalLinkIcon', + ExternalLink +); export const FileTextIcon = /*#__PURE__*/ createIcon('FileTextIcon', FileText); export const FilterIcon = /*#__PURE__*/ createIcon('FilterIcon', ListFilter); +export const Heading1Icon = /*#__PURE__*/ createIcon('Heading1Icon', Heading1); +export const Heading2Icon = /*#__PURE__*/ createIcon('Heading2Icon', Heading2); +export const Heading3Icon = /*#__PURE__*/ createIcon('Heading3Icon', Heading3); +export const Heading4Icon = /*#__PURE__*/ createIcon('Heading4Icon', Heading4); export const InfoIcon = /*#__PURE__*/ createIcon('InfoIcon', Info); +export const ItalicIcon = /*#__PURE__*/ createIcon('ItalicIcon', Italic); +export const LinkIcon = /*#__PURE__*/ createIcon('LinkIcon', Link); +export const ListIcon = /*#__PURE__*/ createIcon('ListIcon', List); export const MinusIcon = /*#__PURE__*/ createIcon('MinusIcon', Minus); export const MoonIcon = /*#__PURE__*/ createIcon('MoonIcon', Moon); +export const NumberedListIcon = /*#__PURE__*/ createIcon( + 'NumberedListIcon', + ListOrdered +); export const PanelLeftIcon = /*#__PURE__*/ createIcon( 'PanelLeftIcon', PanelLeft ); export const PlusIcon = /*#__PURE__*/ createIcon('PlusIcon', Plus); +export const QuoteIcon = /*#__PURE__*/ createIcon('QuoteIcon', TextQuote); +export const RedoIcon = /*#__PURE__*/ createIcon('RedoIcon', Redo2); export const SearchIcon = /*#__PURE__*/ createIcon('SearchIcon', Search); export const ShrinkIcon = /*#__PURE__*/ createIcon('ShrinkIcon', Shrink); export const SortAscendingIcon = /*#__PURE__*/ createIcon( @@ -95,11 +141,22 @@ export const SortDescendingIcon = /*#__PURE__*/ createIcon( ArrowDownWideNarrow ); export const StopIcon = /*#__PURE__*/ createIcon('StopIcon', Square); +export const StrikethroughIcon = /*#__PURE__*/ createIcon( + 'StrikethroughIcon', + Strikethrough +); export const SuccessIcon = /*#__PURE__*/ createIcon('SuccessIcon', CircleCheck); export const SunIcon = /*#__PURE__*/ createIcon('SunIcon', Sun); export const TableIcon = /*#__PURE__*/ createIcon('TableIcon', Table); +/** Regular text, in the editor's text style menu. */ +export const TextIcon = /*#__PURE__*/ createIcon('TextIcon', Type); +export const UnderlineIcon = /*#__PURE__*/ createIcon( + 'UnderlineIcon', + Underline +); /** Restores a value to its default — the calendar's reset. */ export const UndoIcon = /*#__PURE__*/ createIcon('UndoIcon', Undo2); +export const UnlinkIcon = /*#__PURE__*/ createIcon('UnlinkIcon', Unlink); export const WarningIcon = /*#__PURE__*/ createIcon( 'WarningIcon', TriangleAlert diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 0b7fe4d51..2d17fe857 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -114,6 +114,46 @@ export { } from './components/data-view'; export { Dialog } from './components/dialog'; export { Drawer } from './components/drawer'; +export { + defaultShortcuts, + defaultSlashItems, + Editor, + type EditorAction, + type EditorApi, + type EditorBlock, + type EditorBlockButtonProps, + type EditorChangeDetails, + type EditorCommands, + type EditorContentProps, + type EditorFloatingToolbarProps, + type EditorFormat, + type EditorHeadingLevel, + type EditorHeadingMenuProps, + type EditorHistoryButtonProps, + type EditorJSON, + type EditorLinkButtonProps, + type EditorList, + type EditorListMenuProps, + type EditorMark, + type EditorMarkButtonProps, + type EditorMarkdownChangeDetails, + type EditorMention, + type EditorMentionItem, + type EditorMentionRef, + type EditorMentionsProps, + type EditorProps, + type EditorShortcuts, + type EditorSlashItem, + type EditorSlashMenuProps, + type EditorToolbarProps, + editorCommands, + editorToHTML, + editorToText, + MarkdownAdapter, + type MarkdownAdapterOptions, + useEditor, + useEditorState +} from './components/editor'; export { EmptyState } from './components/empty-state'; export { Field } from './components/field'; export { Fieldset } from './components/fieldset'; diff --git a/packages/raystack/package.json b/packages/raystack/package.json index 6cf1710fa..448a3bc2c 100644 --- a/packages/raystack/package.json +++ b/packages/raystack/package.json @@ -132,12 +132,18 @@ "culori": "^4.0.2", "date-fns": "^4.1.0", "dayjs": "^1.11.23", + "marked": "^18.0.14", "prism-react-renderer": "^2.4.1", "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.4", + "prosemirror-gapcursor": "^1.4.1", "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.5.1", "prosemirror-keymap": "^1.2.2", "prosemirror-model": "^1.25.1", + "prosemirror-schema-list": "^1.5.1", "prosemirror-state": "^1.4.3", + "prosemirror-transform": "^1.12.1", "prosemirror-view": "^1.40.0", "react-day-picker": "~10.0.1" }, diff --git a/packages/raystack/vitest.setup.ts b/packages/raystack/vitest.setup.ts index 7693f2984..bdd8265a4 100644 --- a/packages/raystack/vitest.setup.ts +++ b/packages/raystack/vitest.setup.ts @@ -1,52 +1,55 @@ import '@testing-library/jest-dom/vitest'; -// Polyfill ResizeObserver for tests -global.ResizeObserver = class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -}; +// A test that sets `@vitest-environment node` has no DOM to polyfill. +if (typeof window !== 'undefined') { + // Polyfill ResizeObserver for tests + global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }; -const emptyRectList = () => [] as unknown as DOMRectList; + const emptyRectList = () => [] as unknown as DOMRectList; -if (!Range.prototype.getClientRects) { - Range.prototype.getClientRects = emptyRectList; -} -if (!Range.prototype.getBoundingClientRect) { - Range.prototype.getBoundingClientRect = () => new DOMRect(0, 0, 0, 0); -} -if (!Element.prototype.getClientRects) { - Element.prototype.getClientRects = emptyRectList; -} -if (!document.elementFromPoint) { - document.elementFromPoint = () => null; -} + if (!Range.prototype.getClientRects) { + Range.prototype.getClientRects = emptyRectList; + } + if (!Range.prototype.getBoundingClientRect) { + Range.prototype.getBoundingClientRect = () => new DOMRect(0, 0, 0, 0); + } + if (!Element.prototype.getClientRects) { + Element.prototype.getClientRects = emptyRectList; + } + if (!document.elementFromPoint) { + document.elementFromPoint = () => null; + } -// Polyfill PointerEvent for tests (required by @base-ui/react) -if (typeof global.PointerEvent === 'undefined') { - class PointerEvent extends MouseEvent { - readonly pointerId: number; - readonly width: number; - readonly height: number; - readonly pressure: number; - readonly tiltX: number; - readonly tiltY: number; - readonly pointerType: string; - readonly isPrimary: boolean; + // Polyfill PointerEvent for tests (required by @base-ui/react) + if (typeof global.PointerEvent === 'undefined') { + class PointerEvent extends MouseEvent { + readonly pointerId: number; + readonly width: number; + readonly height: number; + readonly pressure: number; + readonly tiltX: number; + readonly tiltY: number; + readonly pointerType: string; + readonly isPrimary: boolean; - constructor(type: string, params: PointerEventInit = {}) { - super(type, params); - this.pointerId = params.pointerId ?? 0; - this.width = params.width ?? 1; - this.height = params.height ?? 1; - this.pressure = params.pressure ?? 0; - this.tiltX = params.tiltX ?? 0; - this.tiltY = params.tiltY ?? 0; - this.pointerType = params.pointerType ?? ''; - this.isPrimary = params.isPrimary ?? false; + constructor(type: string, params: PointerEventInit = {}) { + super(type, params); + this.pointerId = params.pointerId ?? 0; + this.width = params.width ?? 1; + this.height = params.height ?? 1; + this.pressure = params.pressure ?? 0; + this.tiltX = params.tiltX ?? 0; + this.tiltY = params.tiltY ?? 0; + this.pointerType = params.pointerType ?? ''; + this.isPrimary = params.isPrimary ?? false; + } } - } - global.PointerEvent = - PointerEvent as unknown as typeof globalThis.PointerEvent; + global.PointerEvent = + PointerEvent as unknown as typeof globalThis.PointerEvent; + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89c89b12e..0f3a6f9cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -202,24 +202,42 @@ importers: dayjs: specifier: ^1.11.23 version: 1.11.23 + marked: + specifier: ^18.0.14 + version: 18.0.14 prism-react-renderer: specifier: ^2.4.1 version: 2.4.1(react@19.2.1) prosemirror-commands: specifier: ^1.7.1 version: 1.7.1 + prosemirror-dropcursor: + specifier: ^1.8.4 + version: 1.8.4 + prosemirror-gapcursor: + specifier: ^1.4.1 + version: 1.4.1 prosemirror-history: specifier: ^1.4.1 version: 1.5.0 + prosemirror-inputrules: + specifier: ^1.5.1 + version: 1.5.1 prosemirror-keymap: specifier: ^1.2.2 version: 1.2.3 prosemirror-model: specifier: ^1.25.1 version: 1.25.11 + prosemirror-schema-list: + specifier: ^1.5.1 + version: 1.5.1 prosemirror-state: specifier: ^1.4.3 version: 1.4.4 + prosemirror-transform: + specifier: ^1.12.1 + version: 1.12.1 prosemirror-view: specifier: ^1.40.0 version: 1.42.2 @@ -4268,6 +4286,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@18.0.14: + resolution: {integrity: sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -5084,20 +5107,32 @@ packages: prosemirror-commands@1.7.1: resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} + prosemirror-dropcursor@1.8.4: + resolution: {integrity: sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==} + + prosemirror-gapcursor@1.4.1: + resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==} + prosemirror-history@1.5.0: resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + prosemirror-keymap@1.2.3: resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} prosemirror-model@1.25.11: resolution: {integrity: sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==} + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + prosemirror-state@1.4.4: resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} - prosemirror-transform@1.12.0: - resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} + prosemirror-transform@1.12.1: + resolution: {integrity: sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==} prosemirror-view@1.42.2: resolution: {integrity: sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ==} @@ -9623,7 +9658,7 @@ snapshots: optionalDependencies: next: 16.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 - vite: 7.1.3(@types/node@24.10.0) + vite: 7.1.3(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.0) transitivePeerDependencies: - supports-color @@ -10510,6 +10545,8 @@ snapshots: markdown-table@3.0.4: {} + marked@18.0.14: {} + math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: @@ -11592,15 +11629,33 @@ snapshots: dependencies: prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.0 + prosemirror-transform: 1.12.1 + + prosemirror-dropcursor@1.8.4: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + prosemirror-view: 1.42.2 + + prosemirror-gapcursor@1.4.1: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-view: 1.42.2 prosemirror-history@1.5.0: dependencies: prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.0 + prosemirror-transform: 1.12.1 prosemirror-view: 1.42.2 rope-sequence: 1.3.4 + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + prosemirror-keymap@1.2.3: dependencies: prosemirror-state: 1.4.4 @@ -11610,13 +11665,19 @@ snapshots: dependencies: orderedmap: 2.1.1 + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + prosemirror-state@1.4.4: dependencies: prosemirror-model: 1.25.11 - prosemirror-transform: 1.12.0 + prosemirror-transform: 1.12.1 prosemirror-view: 1.42.2 - prosemirror-transform@1.12.0: + prosemirror-transform@1.12.1: dependencies: prosemirror-model: 1.25.11 @@ -11624,7 +11685,7 @@ snapshots: dependencies: prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 - prosemirror-transform: 1.12.0 + prosemirror-transform: 1.12.1 psl@1.15.0: dependencies: @@ -12854,19 +12915,6 @@ snapshots: - tsx - yaml - vite@7.1.3(@types/node@24.10.0): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.48.1 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 24.10.0 - fsevents: 2.3.3 - optional: true - vite@7.1.3(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.39.0): dependencies: esbuild: 0.25.5