From 2ee0b5a80b79f048c76dba7cdd8b8cfa18a14770 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 25 Sep 2026 03:36:59 +0530 Subject: [PATCH 1/6] refactor: [editor] extract shared prosemirror core Move the PromptInput engine into components/editor/core. The suggestion plugin takes per-trigger configs, the mention node view and insertMention read the schema they run in, and the menu hook is a generic useSuggestionMenu. PromptInput behavior and tests are unchanged. --- .../{ => core}/__tests__/markup.test.ts | 2 +- .../editor/{ => core/composer}/markup.ts | 12 +- .../components/editor/core/composer/schema.ts | 25 + .../composer/use-composer-editor.ts} | 87 +--- .../editor-core.module.css} | 0 .../components/editor/{ => core}/index.ts | 35 +- .../raystack/components/editor/core/link.ts | 25 + .../editor/core/mention-commands.ts | 41 ++ .../editor/{ => core}/mention-node-view.ts | 5 +- .../editor/core/mention-registry.ts | 149 ++++++ .../components/editor/{ => core}/mention.ts | 0 .../raystack/components/editor/core/schema.ts | 371 ++++++++++++++ .../editor/{ => core}/suggestion-menu.tsx | 12 +- .../editor/{ => core}/suggestion-plugin.ts | 55 +- .../editor/core/use-suggestion-menu.ts | 485 ++++++++++++++++++ packages/raystack/components/editor/schema.ts | 79 --- .../prompt-input/prompt-input-context.tsx | 2 +- .../prompt-input/prompt-input-editor.tsx | 48 +- .../prompt-input-mention-registry.ts | 160 +----- .../prompt-input/prompt-input-mentions.tsx | 2 +- .../prompt-input/prompt-input-root.tsx | 2 +- .../prompt-input/use-mention-menu.ts | 436 +--------------- 22 files changed, 1267 insertions(+), 766 deletions(-) rename packages/raystack/components/editor/{ => core}/__tests__/markup.test.ts (99%) rename packages/raystack/components/editor/{ => core/composer}/markup.ts (97%) create mode 100644 packages/raystack/components/editor/core/composer/schema.ts rename packages/raystack/components/editor/{use-editor.ts => core/composer/use-composer-editor.ts} (87%) rename packages/raystack/components/editor/{editor.module.css => core/editor-core.module.css} (100%) rename packages/raystack/components/editor/{ => core}/index.ts (51%) create mode 100644 packages/raystack/components/editor/core/link.ts create mode 100644 packages/raystack/components/editor/core/mention-commands.ts rename packages/raystack/components/editor/{ => core}/mention-node-view.ts (96%) create mode 100644 packages/raystack/components/editor/core/mention-registry.ts rename packages/raystack/components/editor/{ => core}/mention.ts (100%) create mode 100644 packages/raystack/components/editor/core/schema.ts rename packages/raystack/components/editor/{ => core}/suggestion-menu.tsx (95%) rename packages/raystack/components/editor/{ => core}/suggestion-plugin.ts (84%) create mode 100644 packages/raystack/components/editor/core/use-suggestion-menu.ts delete mode 100644 packages/raystack/components/editor/schema.ts diff --git a/packages/raystack/components/editor/__tests__/markup.test.ts b/packages/raystack/components/editor/core/__tests__/markup.test.ts similarity index 99% rename from packages/raystack/components/editor/__tests__/markup.test.ts rename to packages/raystack/components/editor/core/__tests__/markup.test.ts index eaf5076cb..9e417c9c1 100644 --- a/packages/raystack/components/editor/__tests__/markup.test.ts +++ b/packages/raystack/components/editor/core/__tests__/markup.test.ts @@ -6,7 +6,7 @@ import { isDocEmpty, serializeMarkup, serializeText -} from '../markup'; +} from '../composer/markup'; import { isTriggerCharacter, serializeMention, trimDetails } from '../mention'; const derive = (markup: string) => deriveDocDetails(docFromMarkup(markup)); diff --git a/packages/raystack/components/editor/markup.ts b/packages/raystack/components/editor/core/composer/markup.ts similarity index 97% rename from packages/raystack/components/editor/markup.ts rename to packages/raystack/components/editor/core/composer/markup.ts index c9b36c6bb..d610867a0 100644 --- a/packages/raystack/components/editor/markup.ts +++ b/packages/raystack/components/editor/core/composer/markup.ts @@ -4,9 +4,9 @@ import { isTriggerCharacter, type MentionAttrs, serializeMention -} from './mention'; +} from '../mention'; import { - editorSchema, + composerSchema, hardBreakType, mentionType, paragraphType @@ -88,7 +88,7 @@ export function inlineFragmentFromText(text: string): Fragment { 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 +105,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 +134,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 +142,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)) ); 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..80810be5f --- /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 '../schema'; + +/** + * 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 87% rename from packages/raystack/components/editor/use-editor.ts rename to packages/raystack/components/editor/core/composer/use-composer-editor.ts index 8ce687218..1b6377a07 100644 --- a/packages/raystack/components/editor/use-editor.ts +++ b/packages/raystack/components/editor/core/composer/use-composer-editor.ts @@ -8,12 +8,24 @@ import { type Command, EditorState, Plugin, - Selection, - TextSelection + Selection } from 'prosemirror-state'; import { Decoration, DecorationSet, EditorView } from 'prosemirror-view'; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import styles from './editor.module.css'; +import styles from '../editor-core.module.css'; +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 +37,12 @@ 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'; /** 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 +65,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 +83,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,7 +93,7 @@ export interface UseEditorResult { initialHtml: { __html: string }; viewRef: React.RefObject; mentionPortals: MentionPortal[]; - actions: EditorActions; + actions: ComposerEditorActions; } function escapeHtml(value: string): string { @@ -103,46 +103,6 @@ function escapeHtml(value: string): string { .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; - }; -} - const insertHardBreak: Command = (state, dispatch) => { if (dispatch) { dispatch( @@ -152,7 +112,9 @@ const insertHardBreak: Command = (state, dispatch) => { return true; }; -export function useEditor(options: UseEditorOptions): UseEditorResult { +export function useComposerEditor( + options: UseComposerEditorOptions +): UseComposerEditorResult { const optionsRef = useRef(options); optionsRef.current = options; @@ -320,7 +282,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 @@ -399,7 +362,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/editor.module.css b/packages/raystack/components/editor/core/editor-core.module.css similarity index 100% rename from packages/raystack/components/editor/editor.module.css rename to packages/raystack/components/editor/core/editor-core.module.css diff --git a/packages/raystack/components/editor/index.ts b/packages/raystack/components/editor/core/index.ts similarity index 51% rename from packages/raystack/components/editor/index.ts rename to packages/raystack/components/editor/core/index.ts index 4e9491cf2..e8b85ceca 100644 --- a/packages/raystack/components/editor/index.ts +++ b/packages/raystack/components/editor/core/index.ts @@ -1,4 +1,3 @@ -export { default as editorStyles } from './editor.module.css'; export { deriveDocDetails, docFromMarkup, @@ -7,7 +6,15 @@ export { isDocEmpty, serializeMarkup, serializeText -} from './markup'; +} from './composer/markup'; +export { composerSchema } from './composer/schema'; +export { + type ComposerEditorActions, + type UseComposerEditorOptions, + type UseComposerEditorResult, + useComposerEditor +} from './composer/use-composer-editor'; +export { default as editorStyles } from './editor-core.module.css'; // ProseMirror-free, so `PromptInput`'s root, textarea and mention registry can // import straight from here without dragging the engine into their graph. export { @@ -19,7 +26,12 @@ export { trimDetails } from './mention'; export type { MentionPortal } from './mention-node-view'; -export { editorSchema } from './schema'; +export { + type EditorMentionItem, + type EditorMentionRef, + MentionRegistry, + type MentionsData +} from './mention-registry'; export { type SuggestionAnchor, type SuggestionGroup, @@ -31,11 +43,16 @@ export { export { dismissSuggestion, insertMention, - type SuggestionState + type SuggestionState, + type SuggestionTrigger } from './suggestion-plugin'; export { - type EditorActions, - type UseEditorOptions, - type UseEditorResult, - useEditor -} from './use-editor'; + filterItems, + type SuggestionItem, + toGroups, + type UseSuggestionMenuOptions, + type UseSuggestionMenuResult, + useMentionRegistryVersion, + useMentionResolution, + useSuggestionMenu +} from './use-suggestion-menu'; diff --git a/packages/raystack/components/editor/core/link.ts b/packages/raystack/components/editor/core/link.ts new file mode 100644 index 000000000..b62f04000 --- /dev/null +++ b/packages/raystack/components/editor/core/link.ts @@ -0,0 +1,25 @@ +const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']); + +const SCHEME = /^([a-z][a-z\d+.-]*:)/i; + +/** + * 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, so they are removed before the check. + */ +export function isSafeHref(href: string): boolean { + // biome-ignore lint/suspicious/noControlCharactersInRegex: the control characters are what is being removed + const compact = href.replace(/[\u0000- \u007f]/g, ''); + const scheme = compact.match(SCHEME); + if (!scheme) return true; + return SAFE_PROTOCOLS.has(scheme[1].toLowerCase()); +} + +/** Adds `https://` to a bare domain, so `example.com` does not become a relative link. */ +export function normalizeHref(input: string): string { + const href = input.trim(); + if (!href) return 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 100% rename from packages/raystack/components/editor/mention.ts rename to packages/raystack/components/editor/core/mention.ts diff --git a/packages/raystack/components/editor/core/schema.ts b/packages/raystack/components/editor/core/schema.ts new file mode 100644 index 000000000..419440273 --- /dev/null +++ b/packages/raystack/components/editor/core/schema.ts @@ -0,0 +1,371 @@ +import { + type MarkSpec, + type NodeSpec, + Schema, + type TagParseRule +} from 'prosemirror-model'; +import { isSafeHref } from './link'; +import type { MentionAttrs } from './mention'; + +/** 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]; + +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'); +} + +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: '@' } + }, + 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 { 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 paragraphNodeSpec: NodeSpec = { + content: 'inline*', + group: 'block', + parseDOM: [{ tag: 'p' }], + toDOM: () => ['p', 0] +}; + +export const hardBreakNodeSpec: NodeSpec = { + inline: true, + group: 'inline', + selectable: false, + parseDOM: [{ tag: 'br' }], + toDOM: () => ['br'] +}; + +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) ? 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; +} + +/** Whether `schema` has the node or mark behind a format. */ +export function hasFormat(schema: Schema, format: EditorFormat): boolean { + return format in schema.nodes || format in schema.marks; +} diff --git a/packages/raystack/components/editor/suggestion-menu.tsx b/packages/raystack/components/editor/core/suggestion-menu.tsx similarity index 95% rename from packages/raystack/components/editor/suggestion-menu.tsx rename to packages/raystack/components/editor/core/suggestion-menu.tsx index 78571023d..c30dc7daa 100644 --- a/packages/raystack/components/editor/suggestion-menu.tsx +++ b/packages/raystack/components/editor/core/suggestion-menu.tsx @@ -2,10 +2,10 @@ 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; @@ -19,9 +19,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. */ diff --git a/packages/raystack/components/editor/suggestion-plugin.ts b/packages/raystack/components/editor/core/suggestion-plugin.ts similarity index 84% rename from packages/raystack/components/editor/suggestion-plugin.ts rename to packages/raystack/components/editor/core/suggestion-plugin.ts index ed5d262ee..be66b83f2 100644 --- a/packages/raystack/components/editor/suggestion-plugin.ts +++ b/packages/raystack/components/editor/core/suggestion-plugin.ts @@ -5,9 +5,8 @@ import { TextSelection } 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 +28,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 +76,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 +97,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 +120,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 +139,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 +197,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 +213,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 +253,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 }) ]); } @@ -281,12 +311,15 @@ export function insertMention( to: state.selection.to }; + const mentionType = state.schema.nodes.mention; + if (!mentionType) return; + 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); 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..06d979b6d --- /dev/null +++ b/packages/raystack/components/editor/core/use-suggestion-menu.ts @@ -0,0 +1,485 @@ +'use client'; + +import { compareItems, rankItem } from '@tanstack/match-sorter-utils'; +import type { EditorView } from 'prosemirror-view'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore +} from 'react'; +import type { EditorMention } from './mention'; +import type { + EditorMentionItem, + EditorMentionRef, + MentionRegistry +} 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: Array<(item: Item) => string | string[]> +): Item[] { + if (!query) return items; + const ranked = items + .map(item => ({ + item, + ranking: rankItem(item, query, { + accessors: accessors as Array<(item: unknown) => string | string[]> + }) + })) + .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: EditorMention[], + refreshLabels: (labels: Map) => void +): void { + const requestedRef = useRef(new Set()); + const refreshRef = useRef(refreshLabels); + refreshRef.current = refreshLabels; + + useEffect(() => { + if (mentions.length === 0) return; + + const byTrigger = new Map(); + + 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: EditorMentionItem[]) => { + 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 + ); + } + refreshRef.current(labels); + }) + .catch(() => { + // Label-only is the fallback, so there is nothing to recover. + }); + } + }, [registry, mentions]); +} 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/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..3d6a7c571 100644 --- a/packages/raystack/components/prompt-input/prompt-input-editor.tsx +++ b/packages/raystack/components/prompt-input/prompt-input-editor.tsx @@ -19,14 +19,15 @@ import { editorStyles, SuggestionMenu, type SuggestionState, - useEditor -} from '../editor'; + useComposerEditor, + useMentionResolution +} from '../editor/core'; 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( () => ({ diff --git a/packages/raystack/components/prompt-input/prompt-input-mention-registry.ts b/packages/raystack/components/prompt-input/prompt-input-mention-registry.ts index 21b494c7d..0701a1713 100644 --- a/packages/raystack/components/prompt-input/prompt-input-mention-registry.ts +++ b/packages/raystack/components/prompt-input/prompt-input-mention-registry.ts @@ -1,154 +1,6 @@ -'use client'; - -import type { ReactNode } from 'react'; -import { mentionKey } from '../editor/mention'; - -export interface PromptInputMentionItem { - 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 PromptInputMentionRef { - type: string; - id: string; - label: string; -} - -/** Everything `PromptInput.Mentions` contributes for one trigger. */ -export interface PromptInputMentionsData { - items?: PromptInputMentionItem[]; - onSearch?: ( - query: string, - context: { trigger: string; signal: AbortSignal } - ) => 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..79ee24b03 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 { - useCallback, - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore -} from 'react'; -import { - type EditorActions, - type SuggestionAnchor, - type SuggestionGroup, + type ComposerEditorActions, type SuggestionState, - suggestionOptionId -} from '../editor'; -import type { PromptInputMention } from './prompt-input-context'; + type UseSuggestionMenuResult, + useMentionRegistryVersion, + useSuggestionMenu +} from '../editor/core'; 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]); -} From b538ec5529a0d82626c1f3f0566157252bc47159 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 25 Sep 2026 04:01:02 +0530 Subject: [PATCH 2/6] feat: [editor] introduce editor component Add Editor on the shared ProseMirror core: a root that owns the state, Content, a fixed Toolbar and a FloatingToolbar built from the same controls, a slash menu and mentions on the suggestion plugin, one shortcut table for keys and tooltips, input rules, list keys, and editorToHTML/editorToText. --- .../editor/__tests__/data-slots.test.tsx | 81 ++ .../editor/__tests__/editor-menus.test.tsx | 267 ++++++ .../editor/__tests__/editor-toolbar.test.tsx | 418 ++++++++++ .../editor/__tests__/editor.test.tsx | 332 ++++++++ .../editor/__tests__/test-utils.tsx | 77 ++ .../editor/core/__tests__/core.test.ts | 299 +++++++ .../components/editor/core/commands.ts | 327 ++++++++ .../components/editor/core/input-rules.ts | 132 +++ .../raystack/components/editor/core/json.ts | 125 +++ .../components/editor/core/keymaps.ts | 111 +++ .../components/editor/core/serializers.ts | 212 +++++ .../components/editor/core/shortcuts.ts | 110 +++ .../editor/core/suggestion-menu.tsx | 7 +- .../editor/core/suggestion-plugin.ts | 18 +- .../components/editor/core/task-item-view.ts | 85 ++ .../components/editor/editor-block-button.tsx | 65 ++ .../components/editor/editor-content.tsx | 120 +++ .../components/editor/editor-context.ts | 62 ++ .../components/editor/editor-control.tsx | 106 +++ .../components/editor/editor-defaults.ts | 84 ++ .../editor/editor-floating-context.ts | 30 + .../editor/editor-floating-toolbar.tsx | 218 +++++ .../components/editor/editor-heading-menu.tsx | 80 ++ .../editor/editor-history-button.tsx | 46 ++ .../components/editor/editor-link-button.tsx | 126 +++ .../components/editor/editor-link-form.tsx | 89 ++ .../components/editor/editor-list-menu.tsx | 63 ++ .../components/editor/editor-mark-button.tsx | 52 ++ .../components/editor/editor-mentions.tsx | 155 ++++ .../components/editor/editor-menu-control.tsx | 102 +++ .../components/editor/editor-root.tsx | 159 ++++ .../components/editor/editor-slash-items.tsx | 151 ++++ .../components/editor/editor-slash-menu.tsx | 124 +++ .../components/editor/editor-store.ts | 766 ++++++++++++++++++ .../components/editor/editor-toolbar.tsx | 57 ++ .../components/editor/editor-types.ts | 99 +++ .../components/editor/editor.module.css | 295 +++++++ .../raystack/components/editor/editor.tsx | 28 + packages/raystack/components/editor/index.tsx | 42 + .../editor/use-editor-suggestion.ts | 78 ++ packages/raystack/icons/icons.tsx | 57 ++ packages/raystack/index.tsx | 38 + packages/raystack/package.json | 5 + pnpm-lock.yaml | 80 +- 44 files changed, 5951 insertions(+), 27 deletions(-) create mode 100644 packages/raystack/components/editor/__tests__/data-slots.test.tsx create mode 100644 packages/raystack/components/editor/__tests__/editor-menus.test.tsx create mode 100644 packages/raystack/components/editor/__tests__/editor-toolbar.test.tsx create mode 100644 packages/raystack/components/editor/__tests__/editor.test.tsx create mode 100644 packages/raystack/components/editor/__tests__/test-utils.tsx create mode 100644 packages/raystack/components/editor/core/__tests__/core.test.ts create mode 100644 packages/raystack/components/editor/core/commands.ts create mode 100644 packages/raystack/components/editor/core/input-rules.ts create mode 100644 packages/raystack/components/editor/core/json.ts create mode 100644 packages/raystack/components/editor/core/keymaps.ts create mode 100644 packages/raystack/components/editor/core/serializers.ts create mode 100644 packages/raystack/components/editor/core/shortcuts.ts create mode 100644 packages/raystack/components/editor/core/task-item-view.ts create mode 100644 packages/raystack/components/editor/editor-block-button.tsx create mode 100644 packages/raystack/components/editor/editor-content.tsx create mode 100644 packages/raystack/components/editor/editor-context.ts create mode 100644 packages/raystack/components/editor/editor-control.tsx create mode 100644 packages/raystack/components/editor/editor-defaults.ts create mode 100644 packages/raystack/components/editor/editor-floating-context.ts create mode 100644 packages/raystack/components/editor/editor-floating-toolbar.tsx create mode 100644 packages/raystack/components/editor/editor-heading-menu.tsx create mode 100644 packages/raystack/components/editor/editor-history-button.tsx create mode 100644 packages/raystack/components/editor/editor-link-button.tsx create mode 100644 packages/raystack/components/editor/editor-link-form.tsx create mode 100644 packages/raystack/components/editor/editor-list-menu.tsx create mode 100644 packages/raystack/components/editor/editor-mark-button.tsx create mode 100644 packages/raystack/components/editor/editor-mentions.tsx create mode 100644 packages/raystack/components/editor/editor-menu-control.tsx create mode 100644 packages/raystack/components/editor/editor-root.tsx create mode 100644 packages/raystack/components/editor/editor-slash-items.tsx create mode 100644 packages/raystack/components/editor/editor-slash-menu.tsx create mode 100644 packages/raystack/components/editor/editor-store.ts create mode 100644 packages/raystack/components/editor/editor-toolbar.tsx create mode 100644 packages/raystack/components/editor/editor-types.ts create mode 100644 packages/raystack/components/editor/editor.module.css create mode 100644 packages/raystack/components/editor/editor.tsx create mode 100644 packages/raystack/components/editor/index.tsx create mode 100644 packages/raystack/components/editor/use-editor-suggestion.ts 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..92e481554 --- /dev/null +++ b/packages/raystack/components/editor/__tests__/data-slots.test.tsx @@ -0,0 +1,81 @@ +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'; + +const PEOPLE = [{ id: 'u1', label: 'Maya Chen' }]; + +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 slot', async () => { + const actionsRef = createRef(); + const { container } = render( + + + + + + + ); + const content = contentOf(container); + act(() => content.focus()); + fireEvent.focus(content); + const view = actionsRef.current?.view; + if (!view) throw new Error('no view'); + expect(getSlot(document.body, 'editor-floating-toolbar')).toBeNull(); + select(view, 1, 6); + await flush(); + expectSlots(document.body, ['editor-floating-toolbar']); + }); + + it('renders the menu slots while they are open', async () => { + const { container } = render( + + + + + + ); + const content = contentOf(container); + expect(getSlot(document.body, 'editor-slash-menu')).toBeNull(); + 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..c8765fd80 --- /dev/null +++ b/packages/raystack/components/editor/__tests__/editor-menus.test.tsx @@ -0,0 +1,267 @@ +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 } 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 "/" with the default commands', async () => { + const { content } = setup(); + paste(content, '/'); + await flush(); + expect(screen.getByRole('listbox', { name: 'Commands' })).toHaveAttribute( + 'data-slot', + 'editor-slash-menu' + ); + expect(screen.getAllByRole('option')).toHaveLength( + defaultSlashItems.length + ); + }); + + it('sets combobox attributes on the content while open', async () => { + const { content } = setup(); + expect(content).toHaveAttribute('aria-expanded', 'false'); + paste(content, '/'); + await flush(); + const listbox = screen.getByRole('listbox'); + expect(content).toHaveAttribute('aria-expanded', 'true'); + expect(content).toHaveAttribute('aria-controls', listbox.id); + expect(content.getAttribute('aria-activedescendant')).toBe( + screen.getAllByRole('option')[0].id + ); + }); + + it('filters on labels and keywords', async () => { + const { content } = setup(); + paste(content, '/todo'); + await flush(); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('Checklist'); + }); + + it('runs the highlighted command on Enter and removes the query', async () => { + const { content, api } = setup(); + paste(content, '/code'); + await flush(); + pressKey(content, 'Enter'); + await flush(); + expect(api().getJSON()).toEqual( + doc({ type: 'codeBlock', attrs: { language: null } }) + ); + expect(screen.queryByRole('listbox')).toBeNull(); + }); + + it('restores the query with one undo', async () => { + const { content, api } = setup(); + paste(content, '/h1'); + await flush(); + pressKey(content, 'Enter'); + await flush(); + expect(api().getHTML()).toBe('

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

/h1

'); + }); + + it('moves the highlight with the arrow keys', async () => { + const { content } = setup(); + paste(content, '/'); + await flush(); + pressKey(content, 'ArrowDown'); + expect(screen.getAllByRole('option')[1]).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + + it('closes on Escape and leaves the text', async () => { + const { content, api } = setup(); + paste(content, '/he'); + await flush(); + pressKey(content, 'Escape'); + await flush(); + expect(screen.queryByRole('listbox')).toBeNull(); + expect(api().getText()).toBe('/he'); + }); + + it('closes when the query gets a space', async () => { + const { content } = setup(); + paste(content, '/he'); + await flush(); + paste(content, ' '); + await flush(); + expect(screen.queryByRole('listbox')).toBeNull(); + }); + + it('does not open in the middle of a word', async () => { + const { content } = setup(); + paste(content, 'and/or'); + await flush(); + expect(screen.queryByRole('listbox')).toBeNull(); + }); + + it('does not open inside a code block', async () => { + const { content } = setup(, { + defaultValue: doc({ type: 'codeBlock' }) + }); + paste(content, '/'); + await flush(); + expect(screen.queryByRole('listbox')).toBeNull(); + }); + + it('runs a custom item with the editor api', async () => { + const run = vi.fn((editor: EditorApi) => { + editor.commands.insertText('today'); + }); + const items: EditorSlashItem[] = [ + { id: 'date', label: "Today's date", keywords: ['time'], run } + ]; + const { content, api } = setup(); + paste(content, '/time'); + await flush(); + fireEvent.click(screen.getByRole('option', { name: /Today's date/ })); + await flush(); + expect(run).toHaveBeenCalledTimes(1); + expect(api().getText()).toBe('today'); + }); + + it('hides built-in commands for formats that are not allowed', async () => { + const { content } = setup(, { + formats: ['bold', 'bulletList'] + }); + paste(content, '/'); + await flush(); + expect( + screen.getAllByRole('option').map(option => option.textContent) + ).toEqual(['TextCtrlAlt0', 'Bulleted listCtrlShift8']); + }); +}); + +describe('Editor.Mentions', () => { + it('inserts a mention chip', async () => { + const { content, api, onValueChange } = setup( + + ); + paste(content, '@ma'); + await flush(); + expect(screen.getByRole('listbox')).toHaveAttribute( + 'data-slot', + 'editor-mention-menu' + ); + pressKey(content, 'Enter'); + await flush(); + expect(api().getJSON()).toEqual( + doc( + p( + { + type: 'mention', + attrs: { id: 'u1', label: 'Maya Chen', type: 'user', trigger: '@' } + }, + ' ' + ) + ) + ); + const details = onValueChange.mock.lastCall?.[1]; + expect(details.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('groups results', async () => { + const { content } = setup(); + paste(content, '@'); + await flush(); + expect(screen.getByText('People')).toBeInTheDocument(); + expect(screen.getByText('Issues')).toBeInTheDocument(); + }); + + 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('searches asynchronously', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const onSearch = vi.fn(async (query: string) => + PEOPLE.filter(item => item.label.toLowerCase().includes(query)) + ); + const { content } = setup(); + paste(content, '@ar'); + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + expect(onSearch).toHaveBeenCalledWith('ar', expect.anything()); + expect(screen.getByRole('option')).toHaveTextContent('Arjun Rao'); + vi.useRealTimers(); + }); + + it('renders nothing without the mention format', async () => { + const { content } = setup(, { + formats: ['bold'] + }); + paste(content, '@'); + await flush(); + expect(screen.queryByRole('listbox')).toBeNull(); + }); + + it('inserts a mention from the api', () => { + const { api } = setup(); + act(() => { + api().commands.insertMention(PEOPLE[1]); + }); + expect(api().getText()).toBe('@Arjun Rao '); + }); +}); 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..935b7f91e --- /dev/null +++ b/packages/raystack/components/editor/__tests__/editor-toolbar.test.tsx @@ -0,0 +1,418 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, it } from 'vitest'; +import { Toolbar } from '../../toolbar'; +import { Editor } from '../editor'; +import type { EditorApi } from '../editor-types'; +import { + contentOf, + doc, + flush, + p, + paste, + pressKey, + select +} from './test-utils'; + +function FixedEditor(props: Partial[0]>) { + return ( + + + + + + + + + + + + + + + + + + ); +} + +function setup(props: Partial[0]> = {}) { + 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, content: contentOf(result.container) }; +} + +describe('Editor.Toolbar', () => { + it('renders a labelled toolbar', () => { + setup(); + const toolbar = screen.getByRole('toolbar', { name: 'Formatting' }); + expect(toolbar).toHaveAttribute('data-slot', 'editor-toolbar'); + }); + + it('does not render when read only', () => { + setup({ readOnly: true }); + expect(screen.queryByRole('toolbar')).toBeNull(); + }); + + it('disables every control when disabled', () => { + setup({ disabled: true }); + expect(screen.getByRole('button', { name: 'Bold' })).toHaveAttribute( + 'aria-disabled', + 'true' + ); + expect(screen.getByRole('button', { name: 'Text style' })).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + + describe('MarkButton', () => { + it('toggles the mark and reflects it with aria-pressed', () => { + const { api, view } = setup(); + select(view(), 1, 6); + const bold = screen.getByRole('button', { name: 'Bold' }); + expect(bold).toHaveAttribute('aria-pressed', 'false'); + fireEvent.click(bold); + expect(api().getHTML()).toBe('

Hello

'); + expect(bold).toHaveAttribute('aria-pressed', 'true'); + expect(bold).toHaveAttribute('data-active'); + }); + + it('prevents the mouse down, so the selection stays', () => { + setup(); + const bold = screen.getByRole('button', { name: 'Bold' }); + const event = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true + }); + bold.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('is disabled inside a code block', () => { + const { view } = setup({ + defaultValue: doc({ + type: 'codeBlock', + content: [{ type: 'text', text: 'x' }] + }) + }); + select(view(), 1, 2); + expect(screen.getByRole('button', { name: 'Bold' })).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + + it('renders nothing for a mark outside formats', () => { + setup({ formats: ['italic'] }); + expect(screen.queryByRole('button', { name: 'Bold' })).toBeNull(); + expect( + screen.getByRole('button', { name: 'Italic' }) + ).toBeInTheDocument(); + }); + + it('shows the label and shortcut in a tooltip', async () => { + setup(); + const bold = screen.getByRole('button', { name: 'Bold' }); + fireEvent.focus(bold); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 300)); + }); + const tooltip = document.querySelector('[data-slot="tooltip-content"]'); + expect(tooltip).toHaveTextContent('Bold'); + expect(tooltip).toHaveTextContent('CtrlB'); + }); + }); + + describe('BlockButton', () => { + it('toggles a quote', () => { + const { api, view } = setup(); + select(view(), 1, 1); + fireEvent.click(screen.getByRole('button', { name: 'Quote' })); + expect(api().getHTML()).toBe('

Hello

'); + expect(screen.getByRole('button', { name: 'Quote' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + fireEvent.click(screen.getByRole('button', { name: 'Quote' })); + expect(api().getHTML()).toBe('

Hello

'); + }); + + it('toggles a code block', () => { + const { api, view } = setup(); + select(view(), 1, 1); + fireEvent.click(screen.getByRole('button', { name: 'Code block' })); + expect(api().getHTML()).toBe('
Hello
'); + }); + + it('inserts a divider without a pressed state', () => { + const { api, view } = setup(); + select(view(), 6, 6); + const divider = screen.getByRole('button', { name: 'Divider' }); + expect(divider).not.toHaveAttribute('aria-pressed'); + fireEvent.click(divider); + expect(api().getHTML()).toBe('

Hello


'); + }); + }); + + describe('HistoryButton', () => { + it('is disabled until there is something to undo', () => { + const { content } = setup(); + const undo = screen.getByRole('button', { name: 'Undo' }); + expect(undo).toHaveAttribute('aria-disabled', 'true'); + paste(content, '!'); + expect(undo).toHaveAttribute('aria-disabled', 'false'); + fireEvent.click(undo); + expect(content).toHaveTextContent(/^Hello$/); + expect(screen.getByRole('button', { name: 'Redo' })).toHaveAttribute( + 'aria-disabled', + 'false' + ); + }); + }); + + describe('HeadingMenu', () => { + it('sets a heading from the menu', async () => { + const { api, view } = setup(); + select(view(), 1, 1); + fireEvent.click(screen.getByRole('button', { name: 'Text style' })); + await flush(); + const item = await screen.findByRole('menuitemradio', { + name: /Heading 2/ + }); + fireEvent.click(item); + await flush(); + expect(api().getHTML()).toBe('

Hello

'); + }); + + it('marks the current style as checked', async () => { + const { view } = setup(); + select(view(), 1, 1); + fireEvent.click(screen.getByRole('button', { name: 'Text style' })); + await flush(); + expect( + await screen.findByRole('menuitemradio', { name: /Text/ }) + ).toHaveAttribute('aria-checked', 'true'); + expect( + screen.getByRole('menuitemradio', { name: /Heading 1/ }) + ).toHaveAttribute('aria-checked', 'false'); + }); + + it('lists only the given levels', async () => { + render( + + + + + + + ); + fireEvent.click(screen.getByRole('button', { name: 'Text style' })); + await flush(); + expect(await screen.findAllByRole('menuitemradio')).toHaveLength(3); + }); + }); + + describe('ListMenu', () => { + it('turns the paragraph into a checklist', async () => { + const { api, view } = setup(); + select(view(), 1, 1); + fireEvent.click(screen.getByRole('button', { name: 'List' })); + await flush(); + fireEvent.click( + await screen.findByRole('menuitemradio', { name: /Checklist/ }) + ); + await flush(); + expect(api().isActive('taskList')).toBe(true); + }); + }); + + describe('LinkButton', () => { + it('opens a URL field and applies the link', 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"'); + }); + + it('opens with Mod-k', async () => { + const { content, view } = setup(); + select(view(), 1, 6); + pressKey(content, 'k', { ctrlKey: true }); + await flush(); + expect( + await screen.findByRole('textbox', { name: 'Link URL' }) + ).toBeInTheDocument(); + }); + + it('removes a link', async () => { + const { api, view } = setup({ + defaultValue: doc({ + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Hello', + marks: [{ type: 'link', attrs: { href: 'https://a.b' } }] + } + ] + }) + }); + select(view(), 2, 2); + fireEvent.click(screen.getByRole('button', { name: 'Link' })); + await flush(); + fireEvent.click( + await screen.findByRole('button', { name: 'Remove link' }) + ); + await flush(); + expect(api().getHTML()).toBe('

Hello

'); + }); + }); + + it('moves focus into the toolbar with Alt-F10', () => { + const { content } = setup(); + pressKey(content, 'F10', { altKey: true }); + expect(document.activeElement).toBe( + screen.getByRole('button', { name: 'Text style' }) + ); + }); +}); + +describe('Editor.FloatingToolbar', () => { + function setupFloating(props: Partial[0]> = {}) { + 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; + }; + const focus = () => { + act(() => content.focus()); + fireEvent.focus(content); + }; + return { ...result, content, view, focus, api: () => actionsRef.current }; + } + + it('shows for a text selection while the editor has focus', async () => { + const { view, focus } = setupFloating(); + focus(); + expect(screen.queryByRole('toolbar')).toBeNull(); + select(view(), 1, 6); + await flush(); + const toolbar = screen.getByRole('toolbar', { name: 'Formatting' }); + expect(toolbar).toHaveAttribute('data-slot', 'editor-floating-toolbar'); + }); + + it('hides when the selection collapses', async () => { + const { view, focus } = setupFloating(); + focus(); + select(view(), 1, 6); + await flush(); + select(view(), 3, 3); + await flush(); + expect(screen.queryByRole('toolbar')).toBeNull(); + }); + + it('waits for the pointer to come up', async () => { + const { view, focus, content } = setupFloating(); + focus(); + fireEvent.mouseDown(content); + select(view(), 1, 6); + await flush(); + expect(screen.queryByRole('toolbar')).toBeNull(); + fireEvent.mouseUp(document); + await flush(); + expect(screen.getByRole('toolbar')).toBeInTheDocument(); + }); + + it('toggles a mark from the floating toolbar', async () => { + const { view, focus, api } = setupFloating(); + focus(); + select(view(), 1, 6); + await flush(); + fireEvent.click(screen.getByRole('button', { name: 'Bold' })); + expect(api()?.getHTML()).toBe('

Hello world

'); + }); + + it('swaps the buttons for the link field', async () => { + const { view, focus, api } = setupFloating(); + focus(); + select(view(), 1, 6); + await flush(); + fireEvent.click(screen.getByRole('button', { name: 'Link' })); + await flush(); + expect(screen.queryByRole('toolbar')).toBeNull(); + const input = screen.getByRole('textbox', { name: 'Link URL' }); + fireEvent.change(input, { target: { value: 'https://raystack.org' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + await flush(); + expect(api()?.getHTML()).toContain('href="https://raystack.org"'); + }); + + it('does not show inside a code block', async () => { + const { view, focus } = setupFloating({ + defaultValue: doc({ + type: 'codeBlock', + content: [{ type: 'text', text: 'const a = 1' }] + }) + }); + focus(); + select(view(), 1, 6); + await flush(); + expect(screen.queryByRole('toolbar')).toBeNull(); + }); + + it('respects shouldShow', async () => { + const actionsRef = createRef(); + const { container } = render( + + + false}> + + + + ); + const content = contentOf(container); + act(() => content.focus()); + fireEvent.focus(content); + const view = actionsRef.current?.view; + if (!view) throw new Error('no view'); + select(view, 1, 6); + await flush(); + expect(screen.queryByRole('toolbar')).toBeNull(); + }); +}); 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..269007e5f --- /dev/null +++ b/packages/raystack/components/editor/__tests__/editor.test.tsx @@ -0,0 +1,332 @@ +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 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', () => { + describe('Rendering', () => { + it('renders the root and an editable textbox', () => { + const { container, content } = setup(); + const root = container.querySelector('[data-slot="editor"]'); + expect(root).toHaveClass(styles.root); + expect(content).toHaveAttribute('role', 'textbox'); + expect(content).toHaveAttribute('aria-multiline', 'true'); + expect(content).toHaveAttribute('contenteditable', 'true'); + expect(screen.getByRole('textbox', { name: 'Description' })).toBe( + content + ); + }); + + it('applies className to the root and the content', () => { + const { container } = render( + + + + ); + expect(container.querySelector('[data-slot="editor"]')).toHaveClass( + 'root-class' + ); + expect(contentOf(container)).toHaveClass('content-class'); + }); + + it('renders defaultValue', () => { + const { content } = setup({ + defaultValue: doc( + { + type: 'heading', + attrs: { level: 2 }, + content: [{ type: 'text', text: 'Title' }] + }, + p('Body') + ) + }); + expect(content.querySelector('h2')).toHaveTextContent('Title'); + expect(content.querySelector('p')).toHaveTextContent('Body'); + }); + + it('marks the root as empty until there is text', () => { + const { container, content } = setup(); + const root = container.querySelector('[data-slot="editor"]'); + expect(root).toHaveAttribute('data-empty'); + paste(content, 'Hello'); + expect(root).not.toHaveAttribute('data-empty'); + }); + + it('shows the placeholder on an empty doc', () => { + const { content } = setup({ placeholder: 'Add description…' }); + expect( + content.querySelector('[data-placeholder="Add description…"]') + ).toBeInTheDocument(); + paste(content, 'x'); + expect(content.querySelector('[data-placeholder]')).toBeNull(); + }); + }); + + describe('Value', () => { + 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(details.getMentions()).toEqual([]); + expect('getMarkdown' in details).toBe(false); + }); + + it('applies a controlled value without reporting it', () => { + const onValueChange = vi.fn(); + const { container, rerender } = render( + + + + ); + rerender( + + + + ); + expect(contentOf(container)).toHaveTextContent('Two'); + 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('loads unknown nodes as text', () => { + const { api } = setup({ + formats: ['bold'], + defaultValue: doc( + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Title' }] + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'a', marks: [{ type: 'italic' }] }] + } + ) + }); + expect(api().getJSON()).toEqual(doc(p('Title'), p('a'))); + }); + }); + + describe('Shortcuts', () => { + it('toggles bold with Mod-b', () => { + const { content, view, api } = setup({ defaultValue: doc(p('Hello')) }); + select(view(), 1, 6); + pressKey(content, 'b', { ctrlKey: true }); + expect(api().isActive('bold')).toBe(true); + expect(api().getHTML()).toBe('

Hello

'); + }); + + it('sets a heading with Mod-Alt-2', () => { + const { content, view, api } = setup({ defaultValue: doc(p('Hello')) }); + select(view(), 1, 1); + pressKey(content, '™', { ctrlKey: true, altKey: true, keyCode: 50 }); + expect(api().getHTML()).toBe('

Hello

'); + }); + + it('turns a shortcut off with false', () => { + const { content, view, api } = setup({ + defaultValue: doc(p('Hello')), + shortcuts: { bold: false } + }); + select(view(), 1, 6); + pressKey(content, 'b', { ctrlKey: true }); + expect(api().isActive('bold')).toBe(false); + }); + }); + + describe('Input rules', () => { + it.each([ + ['# ', '

'], + ['### ', '

'], + ['- ', '
'], + ['1. ', '
'], + ['> ', '

'], + ['```', '

```

'] + ])('turns %j into a block', (input, html) => { + const { view, api } = setup(); + typeText(view(), input); + expect(api().getHTML()).toBe(html); + }); + + it('turns "[] " into a checklist', () => { + const { view, api } = setup(); + typeText(view(), '[] '); + expect(api().getJSON()).toEqual( + doc({ + type: 'taskList', + content: [ + { type: 'taskItem', attrs: { checked: false }, content: [p()] } + ] + }) + ); + }); + + it.each([ + ['**bold**', '

bold

'], + ['_it_', '

it

'], + ['`code`', '

code

'], + ['~~gone~~', '

gone

'] + ])('turns %j into a mark', (input, html) => { + const { view, api } = setup(); + typeText(view(), input); + expect(api().getHTML()).toBe(html); + }); + + it('skips rules for formats that are not allowed', () => { + const { view, api } = setup({ formats: ['bold'] }); + typeText(view(), '# '); + expect(api().getHTML()).toBe('

#

'); + }); + }); + + describe('States', () => { + it('is not editable when disabled', () => { + const { container, content } = setup({ disabled: true }); + expect(container.querySelector('[data-slot="editor"]')).toHaveAttribute( + 'data-disabled' + ); + expect(content).toHaveAttribute('contenteditable', 'false'); + expect(content).toHaveAttribute('aria-disabled', 'true'); + }); + + it('is not editable when read only', () => { + const { container, content } = setup({ readOnly: true }); + expect(container.querySelector('[data-slot="editor"]')).toHaveAttribute( + 'data-readonly' + ); + expect(content).toHaveAttribute('contenteditable', 'false'); + expect(content).toHaveAttribute('aria-readonly', 'true'); + }); + + it('tracks focus on the root', () => { + const { container, content } = setup(); + act(() => content.focus()); + fireEvent.focus(content); + expect(container.querySelector('[data-slot="editor"]')).toHaveAttribute( + 'data-focused' + ); + }); + }); + + describe('API', () => { + it('runs commands and dry runs', () => { + const { api, view } = 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); + api().commands.toggleList('orderedList'); + expect(api().getHTML()).toBe('
  1. Item

'); + api().commands.toggleList('orderedList'); + expect(api().getHTML()).toBe('

Item

'); + }); + + it('sets and removes a link', () => { + const { api, view } = setup({ defaultValue: doc(p('Apsara')) }); + select(view(), 1, 7); + expect(api().commands.setLink('raystack.org')).toBe(true); + expect(api().getHTML()).toBe( + '

Apsara

' + ); + expect(api().commands.setLink('javascript:alert(1)')).toBe(false); + api().commands.unsetLink(); + expect(api().getHTML()).toBe('

Apsara

'); + }); + + it('returns null from getMarkdown without the markdown prop', () => { + const error = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const { api } = setup(); + expect(api().getMarkdown()).toBeNull(); + expect(error).toHaveBeenCalled(); + error.mockRestore(); + }); + + it('sets and clears content, and reports it', () => { + const { api, onValueChange } = setup(); + act(() => { + api().commands.setContent(doc(p('New'))); + }); + expect(api().getText()).toBe('New'); + expect(onValueChange).toHaveBeenCalledTimes(1); + act(() => { + api().commands.clear(); + }); + expect(api().getJSON()).toEqual(doc(p())); + }); + + it('undoes and redoes', () => { + const { api, content } = setup(); + paste(content, 'Hello'); + act(() => { + api().commands.undo(); + }); + expect(api().getText()).toBe(''); + act(() => { + api().commands.redo(); + }); + expect(api().getText()).toBe('Hello'); + }); + }); +}); 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..46ff788d0 --- /dev/null +++ b/packages/raystack/components/editor/__tests__/test-utils.tsx @@ -0,0 +1,77 @@ +import { act, fireEvent } from '@testing-library/react'; +import { TextSelection } from 'prosemirror-state'; +import type { EditorView } from 'prosemirror-view'; +import type { EditorJSON } from '../core/json'; + +export function contentOf(container: ParentNode): HTMLElement { + const node = container.querySelector('[data-slot="editor-content"]'); + if (!node) throw new Error('editor content not found'); + return node as HTMLElement; +} + +/** + * 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, html?: string) { + fireEvent.paste(element, { + clipboardData: { + types: html ? ['text/plain', 'text/html'] : ['text/plain'], + files: [], + getData: (kind: string) => + kind === 'text/plain' ? text : kind === 'text/html' ? (html ?? '') : '' + } + }); +} + +/** 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/__tests__/core.test.ts b/packages/raystack/components/editor/core/__tests__/core.test.ts new file mode 100644 index 000000000..8497f8eac --- /dev/null +++ b/packages/raystack/components/editor/core/__tests__/core.test.ts @@ -0,0 +1,299 @@ +import { EditorState, TextSelection } from 'prosemirror-state'; +import { describe, expect, it } from 'vitest'; +import { clearFormatting, insertHorizontalRule, toggleList } from '../commands'; +import { docFromJSON, type EditorJSON } from '../json'; +import { isSafeHref, normalizeHref } from '../link'; +import { buildSchema } from '../schema'; +import { + docToText, + editorToHTML, + editorToText, + isEditorEmpty +} from '../serializers'; +import { formatShortcut } from '../shortcuts'; + +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 +}); + +function stateOf(json: EditorJSON, from: number, to = from) { + const schema = buildSchema(); + const docNode = docFromJSON(schema, json); + return EditorState.create({ + doc: docNode, + selection: TextSelection.create(docNode, from, to) + }); +} + +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, merging adjacent runs', () => { + expect( + editorToHTML( + doc( + paragraph( + text('a', [{ type: 'bold' }]), + text('b', [{ type: 'bold' }, { type: 'italic' }]), + text('c') + ) + ) + ) + ).toBe('

abc

'); + }); + + it('writes nested content holes', () => { + expect( + editorToHTML( + doc({ + type: 'codeBlock', + attrs: { language: 'ts' }, + content: [text('const a = 1;')] + }) + ) + ).toBe('
const a = 1;
'); + }); + + it('writes task items with a checkbox', () => { + expect( + editorToHTML( + doc({ + type: 'taskList', + content: [ + { + type: 'taskItem', + attrs: { checked: true }, + content: [paragraph(text('Docs'))] + } + ] + }) + ) + ).toBe( + '
  • Docs

' + ); + }); + + it('escapes text and attributes', () => { + expect(editorToHTML(doc(paragraph(text(' & "x"'))))).toBe( + '

<b> & "x"

' + ); + }); + + it('drops links with an unsafe href', () => { + expect( + editorToHTML( + doc( + paragraph( + text('x', [ + { type: 'link', attrs: { href: 'javascript:alert(1)' } } + ]) + ) + ) + ) + ).toBe('

x

'); + }); + + it('writes mentions with their data attributes', () => { + expect( + editorToHTML( + doc( + paragraph({ + type: 'mention', + attrs: { id: 'u1', label: 'Maya', type: 'user', trigger: '@' } + }) + ) + ) + ).toBe( + '

@Maya

' + ); + }); +}); + +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'); + const schema = buildSchema(); + expect(docToText(docFromJSON(schema, value)).mentions).toEqual([ + { + id: 'u1', + label: 'Maya', + type: 'user', + trigger: '@', + start: 11, + end: 16 + } + ]); + }); +}); + +describe('docFromJSON', () => { + const schema = buildSchema(['bold']); + + it('turns unknown blocks into paragraphs and drops unknown marks', () => { + const loaded = docFromJSON( + schema, + doc( + { type: 'table', content: [paragraph(text('cell'))] }, + paragraph(text('x', [{ type: 'italic' }, { type: 'bold' }])) + ) + ); + expect(loaded.toJSON()).toEqual( + doc(paragraph(text('cell')), paragraph(text('x', [{ type: 'bold' }]))) + ); + }); + + it('turns an unknown inline node into its text', () => { + const loaded = docFromJSON( + schema, + doc( + paragraph({ + type: 'mention', + attrs: { id: 'u1', label: 'Maya', type: 'user', trigger: '@' } + }) + ) + ); + expect(loaded.toJSON()).toEqual(doc(paragraph(text('@Maya')))); + }); + + it('returns an empty doc for empty or invalid input', () => { + expect(docFromJSON(schema, doc()).toJSON()).toEqual(doc(paragraph())); + expect( + docFromJSON(schema, { type: 'paragraph' } as EditorJSON).toJSON() + ).toEqual(doc(paragraph())); + }); + + it('clamps heading levels to 4', () => { + const loaded = docFromJSON( + buildSchema(), + doc({ type: 'heading', attrs: { level: 6 }, content: [text('h')] }) + ); + expect(loaded.firstChild?.attrs.level).toBe(4); + }); +}); + +describe('isEditorEmpty', () => { + it('treats whitespace as empty', () => { + const schema = buildSchema(); + expect(isEditorEmpty(docFromJSON(schema, doc(paragraph())))).toBe(true); + expect(isEditorEmpty(docFromJSON(schema, doc(paragraph(text(' ')))))).toBe( + true + ); + expect(isEditorEmpty(docFromJSON(schema, doc(paragraph(text('a')))))).toBe( + false + ); + }); +}); + +describe('commands', () => { + it('converts a bullet list to a checklist in place', () => { + const state = stateOf( + doc({ + type: 'bulletList', + content: [{ type: 'listItem', content: [paragraph(text('a'))] }] + }), + 3 + ); + const { ran, json } = apply(state, toggleList('taskList')); + expect(ran).toBe(true); + expect(json).toEqual( + doc({ + type: 'taskList', + content: [ + { + type: 'taskItem', + attrs: { checked: false }, + content: [paragraph(text('a'))] + } + ] + }) + ); + }); + + it('splits a paragraph around a divider', () => { + const state = stateOf(doc(paragraph(text('abcd'))), 3); + const { json } = apply(state, insertHorizontalRule); + expect(json).toEqual( + doc( + paragraph(text('ab')), + { type: 'horizontalRule' }, + paragraph(text('cd')) + ) + ); + }); + + it('clears marks and reports when there is nothing to clear', () => { + const marked = stateOf( + doc(paragraph(text('ab', [{ type: 'bold' }]))), + 1, + 3 + ); + expect(apply(marked, clearFormatting).json).toEqual( + doc(paragraph(text('ab'))) + ); + const plain = stateOf(doc(paragraph(text('ab'))), 1, 3); + expect(clearFormatting(plain)).toBe(false); + }); +}); + +describe('formatShortcut', () => { + it('uses symbols on macOS', () => { + expect(formatShortcut('Mod-Shift-x', true)).toEqual(['⌘', '⇧', 'X']); + expect(formatShortcut('Alt-F10', true)).toEqual(['⌥', 'F10']); + }); + + it('uses names elsewhere', () => { + expect(formatShortcut('Mod-Alt-1', false)).toEqual(['Ctrl', 'Alt', '1']); + }); + + it('reads a trailing dash as the minus key', () => { + expect(formatShortcut('Mod--', false)).toEqual(['Ctrl', '-']); + }); +}); + +describe('links', () => { + it('allows web, mail and relative links only', () => { + expect(isSafeHref('https://a.b')).toBe(true); + expect(isSafeHref('mailto:a@b.c')).toBe(true); + expect(isSafeHref('/docs')).toBe(true); + expect(isSafeHref('javascript:alert(1)')).toBe(false); + expect(isSafeHref(' java\tscript:alert(1)')).toBe(false); + expect(isSafeHref('data:text/html,x')).toBe(false); + }); + + it('adds a scheme to bare domains and emails', () => { + expect(normalizeHref('raystack.org')).toBe('https://raystack.org'); + expect(normalizeHref('a@b.co')).toBe('mailto:a@b.co'); + expect(normalizeHref('#top')).toBe('#top'); + }); +}); diff --git a/packages/raystack/components/editor/core/commands.ts b/packages/raystack/components/editor/core/commands.ts new file mode 100644 index 000000000..7f24d31f3 --- /dev/null +++ b/packages/raystack/components/editor/core/commands.ts @@ -0,0 +1,327 @@ +import { setBlockType, toggleMark, wrapIn } from 'prosemirror-commands'; +import { redo, redoDepth, undo, undoDepth } 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, EditorList, EditorMark } from './schema'; + +export type EditorBlock = + | 'blockquote' + | 'codeBlock' + | EditorList + | 'horizontalRule'; + +const LISTS: readonly string[] = ['bulletList', 'orderedList', 'taskList']; + +/** 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; + }); + }; +} + +export function setLink(href: string): Command { + return (state, dispatch) => { + const type = state.schema.marks.link; + if (!type) return false; + const normalized = normalizeHref(href); + if (!normalized || !isSafeHref(normalized)) return false; + const { from, to, empty, $from } = state.selection; + if (!$from.parent.type.allowsMarkType(type)) return false; + + 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; +}; + +export const undoCommand: Command = (state, dispatch) => + undoDepth(state) > 0 && undo(state, dispatch); + +export const redoCommand: Command = (state, dispatch) => + redoDepth(state) > 0 && redo(state, dispatch); 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..1a1a09ebd --- /dev/null +++ b/packages/raystack/components/editor/core/input-rules.ts @@ -0,0 +1,132 @@ +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( + new InputRule(/^(?:---|___|\*\*\*)$/, (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..f386e5bed --- /dev/null +++ b/packages/raystack/components/editor/core/json.ts @@ -0,0 +1,125 @@ +import type { Node as PMNode, Schema } from 'prosemirror-model'; +import { isSafeHref } from './link'; + +/** 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; +} + +/** The text inside a node, with mentions written as `@label`. */ +function textOf(json: EditorJSON): string { + if (typeof json.text === 'string') return json.text; + if (json.type === 'mention') { + const trigger = (json.attrs?.trigger as string | undefined) ?? '@'; + return `${trigger}${(json.attrs?.label as string | undefined) ?? ''}`; + } + if (json.type === 'hardBreak') return '\n'; + return (json.content ?? []).map(textOf).join(''); +} + +function textNode(text: string): EditorJSON[] { + return text ? [{ type: 'text', text }] : []; +} + +function sanitizeMarks( + schema: Schema, + marks: EditorJSON['marks'] +): EditorJSON['marks'] { + if (!marks) return undefined; + const kept = marks.filter(mark => { + if (!(mark.type in schema.marks)) return false; + if (mark.type === 'link') { + const href = mark.attrs?.href; + return typeof href === 'string' && isSafeHref(href); + } + return true; + }); + return kept.length ? kept : undefined; +} + +/** + * Rewrites JSON so the schema can load it: a node type the schema does not + * have becomes its text, as a paragraph at block level. An unknown mark is + * dropped, and so is a link with an unsafe `href`. + */ +function sanitize( + schema: Schema, + json: EditorJSON, + inline: boolean +): EditorJSON[] { + if (json.type === 'text') { + if (!json.text) return []; + return [{ ...json, marks: sanitizeMarks(schema, json.marks) }]; + } + + const type = schema.nodes[json.type]; + if (!type) { + const text = textOf(json); + if (inline) return textNode(text); + return [{ type: 'paragraph', content: textNode(text) }]; + } + + const attrs = + json.type === 'heading' + ? { + ...json.attrs, + level: Math.min(Math.max(Number(json.attrs?.level) || 1, 1), 4) + } + : json.attrs; + + return [ + { + ...json, + attrs, + marks: sanitizeMarks(schema, json.marks), + content: json.content?.flatMap(child => + sanitize(schema, child, type.inlineContent) + ) + } + ]; +} + +/** One paragraph per text block, for JSON that still does not fit the schema. */ +function fallbackDoc(schema: Schema, json: EditorJSON): PMNode { + const blocks: string[] = []; + const walk = (node: EditorJSON) => { + const type = schema.nodes[node.type]; + if (type?.isTextblock || (!type && !node.content?.some(c => c.content))) { + blocks.push(textOf(node)); + return; + } + node.content?.forEach(walk); + }; + json.content?.forEach(walk); + const paragraphs = blocks.map(text => + schema.nodes.paragraph.create(null, text ? schema.text(text) : null) + ); + return paragraphs.length + ? schema.topNodeType.create(null, paragraphs) + : emptyDoc(schema); +} + +/** Loads JSON into a document of `schema`. Never throws. */ +export function docFromJSON(schema: Schema, json: EditorJSON): PMNode { + if (!json || json.type !== 'doc' || !json.content?.length) { + return emptyDoc(schema); + } + const [clean] = sanitize(schema, json, false); + try { + const doc = schema.nodeFromJSON(clean); + doc.check(); + return doc; + } catch { + return fallbackDoc(schema, json); + } +} diff --git a/packages/raystack/components/editor/core/keymaps.ts b/packages/raystack/components/editor/core/keymaps.ts new file mode 100644 index 000000000..49e2c1fd5 --- /dev/null +++ b/packages/raystack/components/editor/core/keymaps.ts @@ -0,0 +1,111 @@ +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 { 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); +}; + +const insertHardBreak: Command = (state, dispatch) => { + const type = state.schema.nodes.hardBreak; + if (!type) return false; + dispatch?.(state.tr.replaceSelectionWith(type.create()).scrollIntoView()); + return true; +}; + +/** + * Keys for lists, mentions and block edges. Sits after the shortcut keymap + * and before history and the base keymap. + */ +export function buildEditingKeymap(schema: Schema): 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 + ); + + return keymap({ + 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), + 'Mod-y': redo + }); +} diff --git a/packages/raystack/components/editor/core/serializers.ts b/packages/raystack/components/editor/core/serializers.ts new file mode 100644 index 000000000..333b25761 --- /dev/null +++ b/packages/raystack/components/editor/core/serializers.ts @@ -0,0 +1,212 @@ +import type { + DOMOutputSpec, + Mark, + Node as PMNode, + Schema +} from 'prosemirror-model'; +import { docFromJSON, type EditorJSON } from './json'; +import type { EditorMention, MentionAttrs } from './mention'; +import { buildSchema } from './schema'; + +const VOID_ELEMENTS = new Set(['br', 'hr', 'img', 'input']); + +const HOLE = '\u0000'; + +function escapeText(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>'); +} + +function escapeAttribute(value: string): string { + return escapeText(value).replace(/"/g, '"'); +} + +function isAttrs(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + !('nodeType' in value) + ); +} + +/** Writes a `toDOM` spec as a string. `hole` fills the content slot. */ +function renderSpec(spec: DOMOutputSpec, hole: () => string): string { + if (typeof spec === 'string') return escapeText(spec); + if (!Array.isArray(spec)) return ''; + + const [rawTag, ...rest] = spec as readonly [string, ...unknown[]]; + const tag = rawTag.includes(' ') ? rawTag.split(' ')[1] : rawTag; + 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))}"`; + } + } + + if (VOID_ELEMENTS.has(tag)) return `<${tag}${attrs}>`; + + let inner = ''; + for (const child of children) { + // The hole can sit in a nested element, as in `['pre', ['code', 0]]`. + inner += child === 0 ? hole() : renderSpec(child as DOMOutputSpec, hole); + } + return `<${tag}${attrs}>${inner}`; +} + +function markTags(mark: Mark): [string, string] { + const toDOM = mark.type.spec.toDOM; + if (!toDOM) return ['', '']; + const html = renderSpec(toDOM(mark, true), () => HOLE); + const [open, close = ''] = html.split(HOLE); + return [open, close]; +} + +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); + return renderSpec(toDOM(node), () => serializeChildren(node)); +} + +/** 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 = `${attrs.trigger}${attrs.label}`; + 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 }; +} + +/** The mentions in a document, in document order. */ +export function docMentions(doc: PMNode): EditorMention[] { + return docToText(doc).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 + ); +} + +let fullSchema: Schema | undefined; + +/** Converts editor JSON to HTML. It needs no DOM, so it runs on the server. */ +export function editorToHTML(value: EditorJSON): string { + fullSchema ??= buildSchema(); + return docToHTML(docFromJSON(fullSchema, value)); +} + +/** Converts editor JSON to plain text. Mentions read as `@label`. */ +export function editorToText(value: EditorJSON): string { + fullSchema ??= buildSchema(); + return docToText(docFromJSON(fullSchema, 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/core/suggestion-menu.tsx b/packages/raystack/components/editor/core/suggestion-menu.tsx index c30dc7daa..d35809200 100644 --- a/packages/raystack/components/editor/core/suggestion-menu.tsx +++ b/packages/raystack/components/editor/core/suggestion-menu.tsx @@ -54,6 +54,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 +76,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 +100,7 @@ export function SuggestionMenu({ } as CSSProperties } > -
+
{loading && total === 0 ? Array.from({ length: loadingRowCount }).map((_, index) => (
number | undefined, + className: { item: string; checkbox: string; content: string } + ) { + 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); + } + + 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); + } +} 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..033a0e4da --- /dev/null +++ b/packages/raystack/components/editor/editor-content.tsx @@ -0,0 +1,120 @@ +'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 { 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) + })); + + 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 + } + ); + store.attachView(view); + if (store.autoFocus) { + store.focus(store.autoFocus === true ? undefined : store.autoFocus); + } + return () => { + store.detachView(view); + view.destroy(); + }; + }, [store]); + + 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..2e264be62 --- /dev/null +++ b/packages/raystack/components/editor/editor-control.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { type ComponentProps, 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 function EditorTooltip({ + label, + shortcut, + enabled = true, + trigger +}: { + label: string; + shortcut?: string | false; + enabled?: boolean; + trigger: ReactElement; +}) { + if (!enabled) return trigger; + return ( + + + + + {label} + + + + + ); +} + +export interface EditorControlBaseProps + extends Omit, 'aria-pressed'> { + /** 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` and `data-active`. Leave undefined for a plain button. */ + pressed?: boolean; +} + +/** A toolbar button with a tooltip that shows its label and shortcut. */ +export function EditorControl({ + label, + shortcut, + tooltip = true, + pressed, + onMouseDown, + children, + ...props +}: EditorControlProps) { + const button = ( + { + event.preventDefault(); + onMouseDown?.(event); + }} + {...props} + > + {children} + + ); + return ( + + ); +} diff --git a/packages/raystack/components/editor/editor-defaults.ts b/packages/raystack/components/editor/editor-defaults.ts new file mode 100644 index 000000000..edef53ccc --- /dev/null +++ b/packages/raystack/components/editor/editor-defaults.ts @@ -0,0 +1,84 @@ +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, EditorList, 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 LIST_TYPES: readonly EditorList[] = [ + 'bulletList', + 'orderedList', + 'taskList' +]; + +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..73544b828 --- /dev/null +++ b/packages/raystack/components/editor/editor-floating-context.ts @@ -0,0 +1,30 @@ +'use client'; + +import { createContext, useContext, useEffect } from 'react'; + +export interface FloatingToolbarContextValue { + visible: boolean; + /** Swaps the buttons for the link field. */ + openLink: () => void; + closeLink: () => 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..649a3c20c --- /dev/null +++ b/packages/raystack/components/editor/editor-floating-toolbar.tsx @@ -0,0 +1,218 @@ +'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); + + const visible = + eligible && + (focused || focusWithin || holds > 0 || mode === 'link') && + dismissedAt !== selectionKey; + + useEffect(() => { + if (!eligible) setMode('buttons'); + }, [eligible]); + + 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'), + closeLink: () => setMode('buttons'), + 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 || details.reason !== 'escape-key') return; + setDismissedAt(selectionKey); + setMode('buttons'); + store.view?.focus(); + }} + > + 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..8be87cee5 --- /dev/null +++ b/packages/raystack/components/editor/editor-heading-menu.tsx @@ -0,0 +1,80 @@ +'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, + type EditorMenuOption +} 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()); + // One flag per row, as a string so an unchanged result skips the render. + const can = useStoreSelector(store, state => + [setParagraph, ...levels.map(level => setHeading(level))] + .map(command => (command(state.state) ? '1' : '0')) + .join('') + ); + + if (!store.schema.nodes.heading) return null; + + const options: EditorMenuOption[] = [ + { + key: 'paragraph', + ...PARAGRAPH_DEFAULT, + shortcut: store.shortcuts.paragraph, + active: current === 'paragraph', + disabled: current !== 'paragraph' && can[0] !== '1', + 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] !== '1', + run: () => store.run(setHeading(level)) + }; + }) + ]; + + const Icon = + typeof current === 'number' + ? HEADING_DEFAULTS[current].Icon + : PARAGRAPH_DEFAULT.Icon; + + return ( + } + options={options} + disabled={!editable} + {...props} + /> + ); +} + +EditorHeadingMenu.displayName = 'Editor.HeadingMenu'; 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..93e475a2b --- /dev/null +++ b/packages/raystack/components/editor/editor-history-button.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { RedoIcon, UndoIcon } from '~/icons'; +import { redoCommand, undoCommand } from './core/commands'; +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' ? undoCommand : redoCommand; + 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..8fb327ccc --- /dev/null +++ b/packages/raystack/components/editor/editor-link-button.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { LinkIcon } from '~/icons'; +import { Popover } from '../popover'; +import { Toolbar } from '../toolbar'; +import { activeLink } from './core/commands'; +import styles from './editor.module.css'; +import { useEditorStore, useStoreSelector } from './editor-context'; +import { + EditorControl, + type EditorControlBaseProps, + EditorTooltip +} 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 => { + const type = current.schema.marks.link; + return ( + current.isEditable() && + !!type && + current.state.selection.$from.parent.type.allowsMarkType(type) + ); + }); + + 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 shortcut = store.shortcuts.link; + const content = children ?? ; + + if (floating) { + return ( + { + floating.openLink(); + onClick?.(event); + }} + {...props} + > + {content} + + ); + } + + return ( + + + } + > + {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..0f93b0438 --- /dev/null +++ b/packages/raystack/components/editor/editor-list-menu.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { activeList, toggleList } from './core/commands'; +import type { EditorList } from './core/schema'; +import { useEditorStore, useStoreSelector } from './editor-context'; +import type { EditorControlBaseProps } from './editor-control'; +import { BLOCK_DEFAULTS, LIST_TYPES } from './editor-defaults'; +import { + EditorMenuControl, + type EditorMenuOption +} 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 can = useStoreSelector(store, state => + types.map(type => (toggleList(type)(state.state) ? '1' : '0')).join('') + ); + + const available = types.filter(type => type in store.schema.nodes); + if (available.length === 0) return null; + + const options: EditorMenuOption[] = available.map(type => { + const { action, ...rest } = BLOCK_DEFAULTS[type]; + return { + key: type, + ...rest, + shortcut: action && store.shortcuts[action], + active: current === type, + disabled: can[types.indexOf(type)] !== '1', + run: () => store.run(toggleList(type)) + }; + }); + + const Icon = BLOCK_DEFAULTS[current ?? 'bulletList'].Icon; + + return ( + } + options={options} + disabled={!editable} + {...props} + /> + ); +} + +EditorListMenu.displayName = 'Editor.ListMenu'; 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..ea073d564 --- /dev/null +++ b/packages/raystack/components/editor/editor-mentions.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { type ReactNode, useEffect } from 'react'; +import type { EditorMention } from './core/mention'; +import { isTriggerCharacter } from './core/mention'; +import type { + EditorMentionItem, + EditorMentionRef +} from './core/mention-registry'; +import { docMentions } from './core/serializers'; +import { SuggestionMenu } from './core/suggestion-menu'; +import { + useMentionRegistryVersion, + 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: EditorMention[] = []; + +function sameMentions(a: EditorMention[], b: EditorMention[]): 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; + useMentionRegistryVersion(registry); + + 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]); + + // Pushed after every render and compared field by field, so inline props + // stay live without restarting an in-flight search. + useEffect(() => { + registry.setData(trigger, { + items, + onSearch, + resolveMentions, + onOpenChange, + emptyMessage, + loadingRowCount + }); + }); + + 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); + + 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..58411db4c --- /dev/null +++ b/packages/raystack/components/editor/editor-menu-control.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { type ComponentType, type ReactNode, useMemo, useState } from 'react'; +import { CheckIcon, ChevronDownIcon } from '~/icons'; +import { Menu } from '../menu'; +import { Toolbar } from '../toolbar'; +import styles from './editor.module.css'; +import { useEditorStore } from './editor-context'; +import { + type EditorControlBaseProps, + EditorShortcutKeys, + EditorTooltip +} 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; +} + +interface EditorMenuControlProps extends EditorControlBaseProps { + label: string; + trigger: ReactNode; + options: EditorMenuOption[]; +} + +/** A toolbar button that opens a menu of block types. */ +export function EditorMenuControl({ + label, + tooltip = true, + trigger, + options, + disabled, + ...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 ( + + + } + > + {trigger} + + + } + /> + + {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..c4d0c7565 --- /dev/null +++ b/packages/raystack/components/editor/editor-root.tsx @@ -0,0 +1,159 @@ +'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; +} + +interface EditorJSONProps { + markdown?: undefined; + /** Controlled document. */ + value?: EditorJSON; + /** The first document when uncontrolled. Read once. */ + defaultValue?: EditorJSON; + /** Fires once per doc change. It does not fire for changes made through `value`. */ + onValueChange?: (value: EditorJSON, details: EditorChangeDetails) => void; +} + +interface EditorMarkdownProps { + /** Allows Markdown strings in `value` and `defaultValue`, and parses Markdown on paste. */ + markdown: MarkdownAdapter; + /** Controlled document. A string is parsed as Markdown. */ + value?: EditorJSON | string; + /** The first document when uncontrolled. A string is parsed as Markdown. */ + defaultValue?: EditorJSON | string; + onValueChange?: ( + value: EditorJSON, + details: EditorMarkdownChangeDetails + ) => void; +} + +export type EditorProps = EditorBaseProps & + (EditorJSONProps | EditorMarkdownProps); + +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..343f2fdd0 --- /dev/null +++ b/packages/raystack/components/editor/editor-slash-items.tsx @@ -0,0 +1,151 @@ +import type { EditorFormat } from './core/schema'; +import type { EditorAction } from './core/shortcuts'; +import { + BLOCK_DEFAULTS, + HEADING_DEFAULTS, + PARAGRAPH_DEFAULT +} from './editor-defaults'; +import type { 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; +} + +const builtins = new WeakMap(); + +function builtin(item: EditorSlashItem, meta: Builtin): EditorSlashItem { + builtins.set(item, meta); + return item; +} + +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[] = [ + builtin( + { + id: 'paragraph', + label: 'Text', + group: 'Text', + keywords: ['paragraph', 'plain'], + icon: , + run: editor => editor.commands.setParagraph() + }, + { action: 'paragraph' } + ), + builtin( + { + id: 'heading1', + label: 'Heading 1', + group: 'Text', + keywords: ['h1', 'title'], + icon: , + run: editor => editor.commands.setHeading(1) + }, + { format: 'heading', action: 'heading1' } + ), + builtin( + { + id: 'heading2', + label: 'Heading 2', + group: 'Text', + keywords: ['h2', 'subtitle'], + icon: , + run: editor => editor.commands.setHeading(2) + }, + { format: 'heading', action: 'heading2' } + ), + builtin( + { + id: 'heading3', + label: 'Heading 3', + group: 'Text', + keywords: ['h3'], + icon: , + run: editor => editor.commands.setHeading(3) + }, + { format: 'heading', action: 'heading3' } + ), + builtin( + { + id: 'bulletList', + label: 'Bulleted list', + group: 'Lists', + keywords: ['unordered', 'ul', 'bullet'], + icon: , + run: editor => editor.commands.toggleList('bulletList') + }, + { format: 'bulletList', action: 'bulletList' } + ), + builtin( + { + id: 'orderedList', + label: 'Numbered list', + group: 'Lists', + keywords: ['ordered', 'ol'], + icon: , + run: editor => editor.commands.toggleList('orderedList') + }, + { format: 'orderedList', action: 'orderedList' } + ), + builtin( + { + id: 'taskList', + label: 'Checklist', + group: 'Lists', + keywords: ['todo', 'task', 'checkbox'], + icon: , + run: editor => editor.commands.toggleList('taskList') + }, + { format: 'taskList', action: 'taskList' } + ), + builtin( + { + id: 'blockquote', + label: 'Quote', + group: 'Blocks', + keywords: ['blockquote', 'citation'], + icon: , + run: editor => editor.commands.toggleBlock('blockquote') + }, + { format: 'blockquote', action: 'blockquote' } + ), + builtin( + { + id: 'codeBlock', + label: 'Code block', + group: 'Blocks', + keywords: ['code', 'pre', 'snippet'], + icon: , + run: editor => editor.commands.toggleBlock('codeBlock') + }, + { format: 'codeBlock', action: 'codeBlock' } + ), + builtin( + { + id: 'horizontalRule', + label: 'Divider', + group: 'Blocks', + keywords: ['hr', 'rule', 'separator', 'line'], + icon: , + run: editor => editor.commands.insertHorizontalRule() + }, + { format: 'horizontalRule' } + ) +]; + +export function builtinSlashItem(item: EditorSlashItem): Builtin | undefined { + return builtins.get(item); +} 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..b72ca4abb --- /dev/null +++ b/packages/raystack/components/editor/editor-slash-menu.tsx @@ -0,0 +1,124 @@ +'use client'; + +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 } from './editor-context'; +import { EditorShortcutKeys } from './editor-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 slashAccessors = [ + (item: EditorSlashItem) => item.label, + (item: EditorSlashItem) => item.keywords ?? [] +]; + +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] + ); + + 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: available, + 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, + 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..8b28e565a --- /dev/null +++ b/packages/raystack/components/editor/editor-store.ts @@ -0,0 +1,766 @@ +import { baseKeymap } from 'prosemirror-commands'; +import { dropCursor } from 'prosemirror-dropcursor'; +import { gapCursor } from 'prosemirror-gapcursor'; +import { closeHistory, history } from 'prosemirror-history'; +import { keymap } from 'prosemirror-keymap'; +import { type Node as PMNode, type Schema, Slice } from 'prosemirror-model'; +import { + type Command, + EditorState, + NodeSelection, + Plugin, + Selection, + type Transaction +} from 'prosemirror-state'; +import { Decoration, DecorationSet, type EditorView } from 'prosemirror-view'; +import { + activeLink, + activeTextStyle, + clearFormatting, + insertHorizontalRule, + insertText, + isBlockActive, + isMarkActive, + redoCommand, + setHeading, + setLink, + setParagraph, + toggleBlock, + toggleList, + toggleMarkCommand, + undoCommand, + unsetLink +} from './core/commands'; +import coreStyles from './core/editor-core.module.css'; +import { buildInputRules } from './core/input-rules'; +import { docFromJSON, type EditorJSON, emptyDoc } from './core/json'; +import { buildEditingKeymap } from './core/keymaps'; +import { isSafeHref } from './core/link'; +import type { MentionAttrs } 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'; + +/** Marks transactions that came from `value`, so they are not reported back. */ +export const EXTERNAL = 'apsara-editor-external'; + +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; + +function leafText(node: PMNode): string { + if (node.type.name === 'hardBreak') return '\n'; + if (node.type.name !== 'mention') return ''; + const attrs = node.attrs as MentionAttrs; + return `${attrs.trigger}${attrs.label}`; +} + +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); + }; +} + +/** + * 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; + private markdownOut: { doc: PMNode; markdown: string } | null = null; + private triggers = new Map(); + private toolbars = new Set(); + private linkOpeners = new Set(); + private listeners = new Set<() => void>(); + 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 + ) => + new TaskItemView(node, view, getPos, { + item: styles['task-item'], + checkbox: styles['task-checkbox'], + content: styles['task-content'] + }) + }; + + 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; + const value = doc.toJSON() as EditorJSON; + this.lastValue = value; + this.markdownOut = null; + if (!onValueChange) return; + 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: () => { + const markdown = adapter.fromEditor(value); + this.markdownOut = { 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, or when it loads to the doc the editor already has. + */ + reconcile(value: EditorJSON | string): void { + if (value === this.lastValue) return; + if ( + typeof value === 'string' && + this.markdownOut?.markdown === value && + this.markdownOut.doc === this.state.doc + ) { + this.lastValue = value; + return; + } + this.lastValue = value; + const doc = this.parse(value); + if (doc.eq(this.state.doc)) return; + const tr = this.replaceDocTransaction(doc); + tr.setMeta(EXTERNAL, true); + tr.setMeta('addToHistory', false); + this.dispatch(tr); + } + + 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() }); + 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 handleMouseUp = () => { + if (!this.pointerSelecting) return; + this.pointerSelecting = false; + this.emit(); + }; + + attachView(view: EditorView): void { + this.view = view; + view.dom.ownerDocument.addEventListener('mouseup', this.handleMouseUp); + this.emit(); + } + + detachView(view: EditorView): void { + if (this.view !== view) return; + view.dom.ownerDocument.removeEventListener('mouseup', this.handleMouseUp); + 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), + history(), + keymap(baseKeymap), + dropCursor({ class: styles['drop-cursor'], color: false }), + gapCursor(), + this.placeholderPlugin(), + this.eventsPlugin() + ]; + if (inputRules) plugins.splice(3, 0, inputRules); + return plugins; + } + + private shortcutBindings(): Record { + const commands: Record = { + bold: toggleMarkCommand('bold'), + italic: toggleMarkCommand('italic'), + underline: toggleMarkCommand('underline'), + strike: toggleMarkCommand('strike'), + code: toggleMarkCommand('code'), + link: () => this.openLink(), + paragraph: setParagraph, + heading1: setHeading(1), + heading2: setHeading(2), + heading3: setHeading(3), + heading4: setHeading(4), + bulletList: toggleList('bulletList'), + orderedList: toggleList('orderedList'), + taskList: toggleList('taskList'), + blockquote: toggleBlock('blockquote'), + codeBlock: toggleBlock('codeBlock'), + undo: undoCommand, + redo: redoCommand, + 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 placeholderPlugin(): Plugin { + return new Plugin({ + props: { + decorations: state => { + const text = this.props.placeholder; + const first = state.doc.firstChild; + if (!text || !first || !showsPlaceholder(state.doc)) return null; + return DecorationSet.create(state.doc, [ + Decoration.node(0, first.nodeSize, { + class: coreStyles.placeholder, + 'data-placeholder': text + }) + ]); + } + } + }); + } + + 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 hasHtml = data.types.includes('text/html'); + + // A URL pasted over a selection links the selection. + if ( + text && + !view.state.selection.empty && + URL_PATTERN.test(text.trim()) && + isSafeHref(text.trim()) && + setLink(text.trim())(view.state, view.dispatch) + ) { + return true; + } + + const adapter = this.props.markdown; + if ( + !adapter || + adapter.paste === false || + hasHtml || + !text || + view.state.selection.$from.parent.type.spec.code + ) { + return false; + } + const doc = docFromJSON(this.schema, adapter.toEditor(text)); + view.dispatch( + view.state.tr + .replaceSelection(Slice.maxOpen(doc.content)) + .scrollIntoView() + ); + return true; + }, + clipboardTextSerializer: (slice, view) => { + const adapter = this.props.markdown; + 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', + leafText + ); + } + } + }); + } + + // ---- 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` without touching history. */ + 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(`${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('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(toggleMarkCommand(mark)), + setParagraph: () => runCommand(setParagraph), + setHeading: level => runCommand(setHeading(level)), + toggleBlock: block => runCommand(toggleBlock(block)), + toggleList: list => runCommand(toggleList(list)), + setLink: href => runCommand(setLink(href)), + unsetLink: () => runCommand(unsetLink), + insertHorizontalRule: () => runCommand(insertHorizontalRule), + insertText: text => runCommand(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 }); + // Not focused: the chip belongs at the end of the doc. + const end = Selection.atEnd(state.doc).from; + const range = this.view?.hasFocus() + ? undefined + : { from: end, to: end }; + const tr = mentionTransaction( + state, + { id: item.id, label: item.label, type, trigger }, + range + ); + if (tr) dispatch(tr); + return true; + }), + clearFormatting: () => runCommand(clearFormatting), + undo: () => runCommand(undoCommand), + redo: () => runCommand(redoCommand), + 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 adapter.fromEditor(store.state.doc.toJSON() as EditorJSON); + }, + 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..5e259adcc --- /dev/null +++ b/packages/raystack/components/editor/editor-types.ts @@ -0,0 +1,99 @@ +import type { EditorState } from 'prosemirror-state'; +import type { EditorView } from 'prosemirror-view'; +import type { ReactNode } from 'react'; +import type { EditorBlock } from './core/commands'; +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; + description?: string; + icon?: ReactNode; + group?: string; + keywords?: string[]; + /** Shortcut shown on the row, in ProseMirror keymap syntax. */ + shortcut?: string; + disabled?: boolean; + /** Runs after the menu removes the typed `/query`. */ + run: (editor: EditorApi) => void; +} + +export type { EditorBlock }; diff --git a/packages/raystack/components/editor/editor.module.css b/packages/raystack/components/editor/editor.module.css new file mode 100644 index 000000000..4f2322e3c --- /dev/null +++ b/packages/raystack/components/editor/editor.module.css @@ -0,0 +1,295 @@ +.root { + display: flex; + flex-direction: column; + gap: var(--rs-space-3); + min-width: 0; +} + +/* ---- Content ---- */ + +.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; + min-height: var(--rs-space-8); + outline: none; + white-space: pre-wrap; + overflow-wrap: break-word; + word-break: break-word; + color: var(--rs-color-foreground-base-primary); + 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-regular); +} + +.content[aria-disabled="true"] { + color: var(--rs-color-foreground-base-secondary); +} + +.content > * { + margin: 0; +} + +.content > * + * { + margin-top: var(--rs-space-3); +} + +.content p { + margin: 0; +} + +.content h1, +.content h2, +.content h3, +.content h4 { + margin: 0; + font-family: var(--rs-font-title); + font-weight: var(--rs-font-weight-medium); + color: var(--rs-color-foreground-base-primary); +} + +.content h1 { + font-size: var(--rs-font-size-t2); + line-height: var(--rs-line-height-t2); + letter-spacing: var(--rs-letter-spacing-t2); +} + +.content h2 { + font-size: var(--rs-font-size-t1); + line-height: var(--rs-line-height-t1); + letter-spacing: var(--rs-letter-spacing-t1); +} + +.content h3 { + font-size: var(--rs-font-size-large); + line-height: var(--rs-line-height-large); + letter-spacing: var(--rs-letter-spacing-large); +} + +.content h4 { + font-size: var(--rs-font-size-regular); + line-height: var(--rs-line-height-regular); +} + +.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 { + margin: 0; + padding-left: var(--rs-space-6); +} + +.content li + li, +.content li > * + * { + margin-top: var(--rs-space-2); +} + +.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; + height: var(--editor-line-height); + 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-content { + flex: 1; + min-width: 0; +} + +.task-item[data-checked="true"] > .task-content { + color: var(--rs-color-foreground-base-tertiary); + text-decoration: line-through; +} + +.content blockquote { + margin: 0; + padding-left: var(--rs-space-4); + border-left: 2px solid var(--rs-color-border-base-primary); + color: var(--rs-color-foreground-base-secondary); +} + +.content pre { + margin: 0; + 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); +} + +/* prosemirror-view ships a stylesheet the editor does not load. These rules + from it are load-bearing. */ + +/* ProseMirror appends a zero-size after a block that ends in a + `contentEditable="false"` node, so the browser can draw a caret past it. An + app-level `img { display: block }` reset turns it into a phantom line. */ +.content :global(img.ProseMirror-separator) { + display: inline !important; + border: none !important; + margin: 0 !important; +} + +.content:global(.ProseMirror-hideselection) { + caret-color: transparent; +} + +.content:global(.ProseMirror-hideselection) *::selection { + background: transparent; +} + +.content :global(.ProseMirror-gapcursor) { + display: none; + position: absolute; + pointer-events: none; +} + +.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); +} + +.content:global(.ProseMirror-focused) :global(.ProseMirror-gapcursor) { + display: block; +} + +.drop-cursor { + background: var(--rs-color-border-accent-emphasis); +} + +/* 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); +} + +/* ---- Toolbars ---- */ + +.toolbar { + flex-wrap: wrap; +} + +.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; +} + +.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); +} + +.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.tsx b/packages/raystack/components/editor/index.tsx new file mode 100644 index 000000000..ed6906906 --- /dev/null +++ b/packages/raystack/components/editor/index.tsx @@ -0,0 +1,42 @@ +export type { EditorBlock } 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, + MarkdownAdapter +} from './editor-types'; 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/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..b0413981a 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -114,6 +114,44 @@ 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, + editorToHTML, + editorToText, + type MarkdownAdapter, + 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..4f8dabd88 100644 --- a/packages/raystack/package.json +++ b/packages/raystack/package.json @@ -134,10 +134,15 @@ "dayjs": "^1.11.23", "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/pnpm-lock.yaml b/pnpm-lock.yaml index 89c89b12e..d27c1b27a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -208,18 +208,33 @@ importers: 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 @@ -5084,20 +5099,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 +9650,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 @@ -11592,15 +11619,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 +11655,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 +11675,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 +12905,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 From da5f50d59bbd6119ac2d29c7123b3d13e2a45868 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 25 Sep 2026 04:10:09 +0530 Subject: [PATCH 3/6] feat: [editor] markdown adapter Add MarkdownAdapter with create(), toEditor() and fromEditor(). The markdown prop allows Markdown strings in value and defaultValue, parses plain-text Markdown on paste, and adds details.getMarkdown(). marked is only reached through the adapter, and a bundle test checks that an app without it ships no marked. --- .../components/editor/core/composer/markup.ts | 60 +--- .../components/editor/core/mention.ts | 62 ++++ .../components/editor/editor-root.tsx | 39 +-- packages/raystack/components/editor/index.tsx | 4 +- .../editor/markdown/__tests__/bundle.test.ts | 89 +++++ .../markdown/__tests__/markdown.test.tsx | 325 ++++++++++++++++++ .../editor/markdown/from-markdown.ts | 298 ++++++++++++++++ .../components/editor/markdown/index.ts | 53 +++ .../components/editor/markdown/to-markdown.ts | 270 +++++++++++++++ packages/raystack/index.tsx | 3 +- packages/raystack/package.json | 1 + pnpm-lock.yaml | 10 + 12 files changed, 1131 insertions(+), 83 deletions(-) create mode 100644 packages/raystack/components/editor/markdown/__tests__/bundle.test.ts create mode 100644 packages/raystack/components/editor/markdown/__tests__/markdown.test.tsx create mode 100644 packages/raystack/components/editor/markdown/from-markdown.ts create mode 100644 packages/raystack/components/editor/markdown/index.ts create mode 100644 packages/raystack/components/editor/markdown/to-markdown.ts diff --git a/packages/raystack/components/editor/core/composer/markup.ts b/packages/raystack/components/editor/core/composer/markup.ts index d610867a0..004e0c0a5 100644 --- a/packages/raystack/components/editor/core/composer/markup.ts +++ b/packages/raystack/components/editor/core/composer/markup.ts @@ -3,6 +3,7 @@ import { type EditorMention, isTriggerCharacter, type MentionAttrs, + readMention, serializeMention } from '../mention'; import { @@ -23,65 +24,6 @@ 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[] = []; diff --git a/packages/raystack/components/editor/core/mention.ts b/packages/raystack/components/editor/core/mention.ts index e7217c3d7..3a6f3bbba 100644 --- a/packages/raystack/components/editor/core/mention.ts +++ b/packages/raystack/components/editor/core/mention.ts @@ -58,6 +58,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/editor-root.tsx b/packages/raystack/components/editor/editor-root.tsx index c4d0c7565..fd9f21c6e 100644 --- a/packages/raystack/components/editor/editor-root.tsx +++ b/packages/raystack/components/editor/editor-root.tsx @@ -55,33 +55,30 @@ interface EditorBaseProps actionsRef?: RefObject; } -interface EditorJSONProps { - markdown?: undefined; - /** Controlled document. */ - value?: EditorJSON; - /** The first document when uncontrolled. Read once. */ - defaultValue?: EditorJSON; - /** Fires once per doc change. It does not fire for changes made through `value`. */ - onValueChange?: (value: EditorJSON, details: EditorChangeDetails) => void; -} +type WithMarkdown = [M] extends [MarkdownAdapter] ? Yes : No; -interface EditorMarkdownProps { +/** + * `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: MarkdownAdapter; - /** Controlled document. A string is parsed as Markdown. */ - value?: EditorJSON | string; - /** The first document when uncontrolled. A string is parsed as Markdown. */ - defaultValue?: EditorJSON | string; + 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`. */ onValueChange?: ( value: EditorJSON, - details: EditorMarkdownChangeDetails + details: WithMarkdown ) => void; } -export type EditorProps = EditorBaseProps & - (EditorJSONProps | EditorMarkdownProps); - -export function EditorRoot({ +export function EditorRoot({ value, defaultValue, onValueChange, @@ -98,7 +95,7 @@ export function EditorRoot({ ref, children, ...props -}: EditorProps) { +}: EditorProps) { const storeProps = { placeholder, disabled, diff --git a/packages/raystack/components/editor/index.tsx b/packages/raystack/components/editor/index.tsx index ed6906906..bfc8c7540 100644 --- a/packages/raystack/components/editor/index.tsx +++ b/packages/raystack/components/editor/index.tsx @@ -37,6 +37,6 @@ export type { EditorChangeDetails, EditorCommands, EditorMarkdownChangeDetails, - EditorSlashItem, - MarkdownAdapter + EditorSlashItem } from './editor-types'; +export { MarkdownAdapter, type MarkdownAdapterOptions } from './markdown'; diff --git a/packages/raystack/components/editor/markdown/__tests__/bundle.test.ts b/packages/raystack/components/editor/markdown/__tests__/bundle.test.ts new file mode 100644 index 000000000..cdba64c17 --- /dev/null +++ b/packages/raystack/components/editor/markdown/__tests__/bundle.test.ts @@ -0,0 +1,89 @@ +import { mkdtempSync, 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 = process.cwd(); +const EDITOR_INDEX = resolve(PACKAGE_ROOT, 'components/editor/index.tsx'); + +/** 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-')); + 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 declares `"sideEffects": false`, so an app's bundler drops + // an Apsara module whose exports it does not use. + treeshake: { moduleSideEffects: (_id, external) => external }, + 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'); +} + +describe('Markdown bundle cost', () => { + it('leaves marked out of a JSON-only app', async () => { + const code = await bundle( + `import { Editor } from ${JSON.stringify(EDITOR_INDEX)};\n` + + 'export const editor = Editor;\n' + ); + expect(code).toContain('Editor.Content'); + expect(code).not.toMatch(/from ['"]marked['"]/); + }, 120_000); + + it('includes marked when the app imports the adapter', async () => { + const code = await bundle( + `import { MarkdownAdapter } from ${JSON.stringify(EDITOR_INDEX)};\n` + + 'export const adapter = MarkdownAdapter.create();\n' + ); + expect(code).toMatch(/from ['"]marked['"]/); + }, 120_000); +}); diff --git a/packages/raystack/components/editor/markdown/__tests__/markdown.test.tsx b/packages/raystack/components/editor/markdown/__tests__/markdown.test.tsx new file mode 100644 index 000000000..41397ffbe --- /dev/null +++ b/packages/raystack/components/editor/markdown/__tests__/markdown.test.tsx @@ -0,0 +1,325 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { render } from '@testing-library/react'; +import { createRef, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { contentOf, paste } from '../../__tests__/test-utils'; +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 '../index'; + +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 +}); + +/** 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; +} + +const EVERYTHING = doc( + { type: 'heading', attrs: { level: 1 }, content: [t('Release notes')] }, + { type: 'heading', attrs: { level: 4 }, content: [t('Small')] }, + 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', [{ type: 'link', attrs: { href: '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')), + { + type: 'bulletList', + content: [{ type: 'listItem', content: [para(t('Nested'))] }] + } + ] + }, + { type: 'listItem', content: [para(t('Two'))] } + ] + }, + { + type: 'orderedList', + attrs: { start: 3 }, + content: [ + { type: 'listItem', content: [para(t('Three'))] }, + { type: 'listItem', content: [para(t('Four'))] } + ] + }, + { + type: 'taskList', + content: [ + { + type: 'taskItem', + attrs: { checked: true }, + content: [para(t('Done'))] + }, + { + type: 'taskItem', + attrs: { checked: false }, + content: [para(t('Todo'))] + } + ] + }, + para(t('Special *chars* _here_ [x] # not a heading')) +); + +describe('MarkdownAdapter', () => { + it('round-trips every node and mark', () => { + const markdown = MarkdownAdapter.fromEditor(EVERYTHING); + expect(normalize(MarkdownAdapter.toEditor(markdown))).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('.')), + { + type: 'taskList', + content: [ + { + type: 'taskItem', + attrs: { checked: true }, + content: [para(t('Docs'))] + } + ] + } + ) + ) + ).toBe('## Title\n\nShip the **fix**.\n\n- [x] Docs'); + }); + + it('moves whitespace outside marks', () => { + expect( + MarkdownAdapter.fromEditor( + doc(para(t('a'), t(' bold ', [{ type: 'bold' }]), t('b'))) + ) + ).toBe('a **bold** b'); + }); + + it('writes mentions as labels with mentions: "label"', () => { + const adapter = MarkdownAdapter.create({ mentions: 'label' }); + expect( + adapter.fromEditor( + doc( + para({ + type: 'mention', + attrs: { id: '1', label: 'Maya', type: 'user', trigger: '@' } + }) + ) + ) + ).toBe('@Maya'); + }); + + it('drops underline with underline: "drop"', () => { + const adapter = MarkdownAdapter.create({ underline: 'drop' }); + expect(adapter.fromEditor(doc(para(t('u', [{ type: 'underline' }]))))).toBe( + 'u' + ); + }); + + it('keeps raw HTML as text and drops unsafe links', () => { + expect( + MarkdownAdapter.toEditor('\n\n[a](javascript:alert(1))') + ).toEqual(doc(para(t('')), para(t('a')))); + }); + + it('does not read an image as a mention', () => { + expect(MarkdownAdapter.toEditor('![alt](https://x/y.png)')).toEqual( + doc(para(t('alt'))) + ); + }); + + it('clamps heading levels and turns tables into text', () => { + expect( + MarkdownAdapter.toEditor('###### Deep\n\n| a | b |\n| - | - |\n| 1 | 2 |') + ).toEqual( + doc( + { type: 'heading', attrs: { level: 4 }, content: [t('Deep')] }, + para(t('a | b')), + para(t('1 | 2')) + ) + ); + }); +}); + +describe('Editor with the markdown prop', () => { + 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, '!'); + const [value, details] = onValueChange.mock.calls[0]; + expect(value.type).toBe('doc'); + expect(details.getMarkdown()).toBe('**bold** text!'); + }); + + it('parses pasted plain-text Markdown', () => { + const actionsRef = createRef(); + const { container } = render( + + + + ); + paste(contentOf(container), '## Heading\n\n- one\n- two'); + expect(actionsRef.current?.getHTML()).toBe( + '

Heading

  • one

  • two

' + ); + }); + + it('leaves pastes as text with paste: false', () => { + const actionsRef = createRef(); + const { container } = render( + + + + ); + paste(contentOf(container), '**x**'); + expect(actionsRef.current?.getHTML()).toBe('

**x**

'); + }); + + it('keeps the doc when a controlled string is the one it produced', () => { + function Controlled() { + const [markdown, setMarkdown] = useState('Hello'); + return ( + setMarkdown(details.getMarkdown())} + > + + + ); + } + const { container } = render(); + const content = contentOf(container); + paste(content, ' world'); + paste(content, '!'); + expect(content).toHaveTextContent('Hello world!'); + }); + + it('replaces the doc for a new controlled string', () => { + const adapter = MarkdownAdapter.create(); + const { container, rerender } = render( + + + + ); + rerender( + + + + ); + expect(contentOf(container).querySelector('h1')).toHaveTextContent('Two'); + }); + + it('returns Markdown from the api', () => { + const actionsRef = createRef(); + render( + + + + ); + expect(actionsRef.current?.getMarkdown()).toBe('_hi_'); + }); + + it('warns and loads plain text for a string without the prop', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const actionsRef = createRef(); + render( + // @ts-expect-error a string value needs the markdown prop + + + + ); + expect(warn).toHaveBeenCalled(); + expect(actionsRef.current?.getText()).toBe('**x**'); + warn.mockRestore(); + }); +}); + +describe('bundle boundary', () => { + it('has no module outside markdown/ importing from it', () => { + const root = resolve(__dirname, '../..'); + const offenders: string[] = []; + const walk = (dir: string) => { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) { + if (name !== 'markdown' && name !== '__tests__') walk(path); + continue; + } + if (!/\.tsx?$/.test(name) || name === 'index.tsx') continue; + const source = readFileSync(path, 'utf8'); + if (/from '\.{1,2}\/(?:\.\.\/)*markdown/.test(source)) { + offenders.push(path.slice(root.length + 1)); + } + } + }; + walk(root); + expect(offenders).toEqual([]); + }); +}); 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..aee295737 --- /dev/null +++ b/packages/raystack/components/editor/markdown/from-markdown.ts @@ -0,0 +1,298 @@ +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 = /["-/:-@^`{-~]\[/; + +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) 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: ' ' +}; + +function decodeEntities(text: string): string { + return text.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (match, entity) => { + if (entity[0] === '#') { + const code = + entity[1] === 'x' || entity[1] === 'X' + ? Number.parseInt(entity.slice(2), 16) + : Number.parseInt(entity.slice(1), 10); + return Number.isNaN(code) ? match : String.fromCodePoint(code); + } + return ENTITIES[entity.toLowerCase()] ?? match; + }); +} + +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': + nodes.push( + ...textNodes( + decodeEntities((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; +} + +function list(token: Tokens.List): EditorJSON { + if (token.items.some(item => item.task)) { + return { + type: 'taskList', + content: token.items.map(item => ({ + type: 'taskItem', + attrs: { checked: item.checked === true }, + content: itemContent(item.tokens) + })) + }; + } + 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..2896dfed9 --- /dev/null +++ b/packages/raystack/components/editor/markdown/to-markdown.ts @@ -0,0 +1,270 @@ +// 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 { type MentionAttrs, serializeMention } from '../core/mention'; + +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']; + +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 escapeText(text: string, atLineStart: boolean): string { + let escaped = text + .replace(/[`*\\~[\]_<]/g, '\\$&') + .replace(/&(?=#?\w+;)/g, '\\&'); + if (atLineStart) { + escaped = escaped + .replace(/^(\s*)([-+>]|#{1,6})(?=\s|$)/, '$1\\$2') + .replace(/^(\s*\d+)([.)])(?=\s|$)/, '$1\\$2'); + } + return escaped; +} + +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; + + constructor(private readonly options: ToMarkdownOptions) {} + + private open(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 close(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(String(mark.attrs?.href ?? ''))})`; + default: + return ''; + } + } + + /** Closes the marks past `keep`, then writes the whitespace held back. */ + private settle(keep: number) { + while (this.active.length > keep) { + const mark = this.active.pop(); + if (mark) this.out += this.close(mark); + } + this.out += 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) { + let marks = [...(node.marks ?? [])].sort((a, b) => rank(a) - rank(b)); + const code = marks.find(mark => mark.type === 'code'); + marks = marks.filter(mark => mark.type !== 'code'); + + if (node.type === 'text') { + const text = node.text ?? ''; + // Whitespace-only text opens no new marks, so no `** **` is written. + if (text.trim() === '' && !code) { + marks = marks.slice(0, this.commonPrefix(marks)); + } + } + + const keep = this.commonPrefix(marks); + this.settle(keep); + + if (node.type === 'text') { + const text = node.text ?? ''; + 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); + // Markdown does not allow whitespace just inside a delimiter, so it + // moves outside the marks. + const opening = marks.length > keep; + if (opening || code) this.out += lead; + for (const mark of marks.slice(keep)) this.out += this.open(mark); + this.active = marks; + let body = escapeText(opening ? core : lead + core, this.atLineStart); + if (code) body = core ? codeSpan(core) : ''; + this.out += body; + if (code || marks.length) this.trailing = trail; + else this.out += trail; + if (text.length) this.atLineStart = false; + return; + } + + for (const mark of marks.slice(keep)) this.out += this.open(mark); + this.active = marks; + + if (node.type === 'hardBreak') { + this.out += '\\\n'; + this.atLineStart = true; + return; + } + if (node.type === 'mention') { + const attrs = node.attrs as unknown as MentionAttrs; + this.out += + this.options.mentions === 'label' + ? escapeText(`${attrs.trigger}${attrs.label}`, this.atLineStart) + : serializeMention(attrs); + this.atLineStart = false; + } + } + + finish(): string { + this.settle(0); + return this.out; + } +} + +function inline(content: EditorJSON[] | undefined, options: ToMarkdownOptions) { + const writer = new InlineWriter(options); + for (const node of content ?? []) writer.write(node); + 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(''); +} + +const LISTS = ['bulletList', 'orderedList', 'taskList']; + +function listItem(item: EditorJSON, options: ToMarkdownOptions): string { + const children = item.content ?? []; + let body = ''; + children.forEach((child, index) => { + if (index > 0) body += LISTS.includes(child.type) ? '\n' : '\n\n'; + body += block(child, options); + }); + return body; +} + +function list(node: EditorJSON, options: ToMarkdownOptions): 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); + return items + .map((item, index) => { + let marker = '- '; + if (node.type === 'orderedList') marker = `${start + index}. `; + if (node.type === 'taskList') { + marker = item.attrs?.checked ? '- [x] ' : '- [ ] '; + } + const body = listItem(item, options); + return indent(body, marker, ' '.repeat(marker.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 = Number(node.attrs?.level ?? 1); + return `${'#'.repeat(level)} ${inline(node.content, options)}`; + } + 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 '---'; + case 'bulletList': + case 'orderedList': + case 'taskList': + return list(node, options); + default: + return node.content ? blocks(node.content, options) : (node.text ?? ''); + } +} + +function blocks( + content: EditorJSON[] | undefined, + options: ToMarkdownOptions +): string { + return (content ?? []).map(node => block(node, options)).join('\n\n'); +} + +/** Serializes editor JSON as Markdown. */ +export function toMarkdown(value: EditorJSON, options: ToMarkdownOptions) { + return blocks(value.content, options); +} diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index b0413981a..d516f3fd7 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -148,7 +148,8 @@ export { type EditorToolbarProps, editorToHTML, editorToText, - type MarkdownAdapter, + MarkdownAdapter, + type MarkdownAdapterOptions, useEditor, useEditorState } from './components/editor'; diff --git a/packages/raystack/package.json b/packages/raystack/package.json index 4f8dabd88..448a3bc2c 100644 --- a/packages/raystack/package.json +++ b/packages/raystack/package.json @@ -132,6 +132,7 @@ "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d27c1b27a..0f3a6f9cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -202,6 +202,9 @@ 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) @@ -4283,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'} @@ -10537,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: From c738909c09205fdb77731a3aed11d6d5e2f1eedf Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 25 Sep 2026 04:22:05 +0530 Subject: [PATCH 4/6] fix: [editor] keep menu triggers mounted and block spacing The tooltip wrapper was removed while a menu or link popover was open, which remounted the trigger and left the popup without an anchor. The paragraph margin reset also overrode the spacing between blocks. --- .../editor/__tests__/editor-toolbar.test.tsx | 6 +++++- .../raystack/components/editor/editor-link-button.tsx | 2 +- .../components/editor/editor-menu-control.tsx | 2 +- packages/raystack/components/editor/editor.module.css | 11 ++--------- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/raystack/components/editor/__tests__/editor-toolbar.test.tsx b/packages/raystack/components/editor/__tests__/editor-toolbar.test.tsx index 935b7f91e..e44592cd7 100644 --- a/packages/raystack/components/editor/__tests__/editor-toolbar.test.tsx +++ b/packages/raystack/components/editor/__tests__/editor-toolbar.test.tsx @@ -190,8 +190,12 @@ describe('Editor.Toolbar', () => { it('sets a heading from the menu', async () => { const { api, view } = setup(); select(view(), 1, 1); - fireEvent.click(screen.getByRole('button', { name: 'Text style' })); + 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(trigger).toBeInTheDocument(); const item = await screen.findByRole('menuitemradio', { name: /Heading 2/ }); diff --git a/packages/raystack/components/editor/editor-link-button.tsx b/packages/raystack/components/editor/editor-link-button.tsx index 8fb327ccc..a653696fc 100644 --- a/packages/raystack/components/editor/editor-link-button.tsx +++ b/packages/raystack/components/editor/editor-link-button.tsx @@ -92,7 +92,7 @@ export function EditorLinkButton({ * { +/* Zero specificity, so the spacing rules below win over the reset. */ +.content :where(p, h1, h2, h3, h4, ul, ol, blockquote, pre) { margin: 0; } @@ -38,15 +39,10 @@ margin-top: var(--rs-space-3); } -.content p { - margin: 0; -} - .content h1, .content h2, .content h3, .content h4 { - margin: 0; font-family: var(--rs-font-title); font-weight: var(--rs-font-weight-medium); color: var(--rs-color-foreground-base-primary); @@ -83,7 +79,6 @@ .content ul, .content ol { - margin: 0; padding-left: var(--rs-space-6); } @@ -129,14 +124,12 @@ } .content blockquote { - margin: 0; padding-left: var(--rs-space-4); border-left: 2px solid var(--rs-color-border-base-primary); color: var(--rs-color-foreground-base-secondary); } .content pre { - margin: 0; padding: var(--rs-space-3) var(--rs-space-4); border-radius: var(--rs-radius-2); background: var(--rs-color-background-base-secondary); From 9d824009fd2c6fc954c95a8c75988f94c2a21285 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 25 Sep 2026 04:22:11 +0530 Subject: [PATCH 5/6] feat: [editor] docs Add the Editor docs page with a playground, examples for each toolbar, mentions, slash commands, controlled JSON and Markdown, and list the new editor icon keys on the icons page. --- .../content/docs/components/editor/demo.ts | 366 +++++++++++++++ .../content/docs/components/editor/index.mdx | 268 +++++++++++ .../content/docs/components/editor/props.ts | 429 ++++++++++++++++++ .../src/content/docs/theme/icons/index.mdx | 5 + 4 files changed, 1068 insertions(+) create mode 100644 apps/www/src/content/docs/components/editor/demo.ts create mode 100644 apps/www/src/content/docs/components/editor/index.mdx create mode 100644 apps/www/src/content/docs/components/editor/props.ts 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..93f04e802 --- /dev/null +++ b/apps/www/src/content/docs/components/editor/index.mdx @@ -0,0 +1,268 @@ +--- +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. + + + + + +### 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. + + + + + +### 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. + + + +## 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. + + + +### 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. + + + +### 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 becomes a paragraph with its text, and an unknown mark is dropped. + +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 `---` 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. + +| 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` | +| 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-expanded`, `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, and Escape in the floating toolbar returns 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. 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..389bb392d --- /dev/null +++ b/apps/www/src/content/docs/components/editor/props.ts @@ -0,0 +1,429 @@ +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`. 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?: Array< + | 'bold' + | 'italic' + | 'underline' + | 'strike' + | 'code' + | 'link' + | 'heading' + | 'blockquote' + | 'codeBlock' + | 'bulletList' + | 'orderedList' + | 'taskList' + | 'horizontalRule' + | 'mention' + >; + + /** 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; + 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 fill it in. */ + 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: string, 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 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 From 7fb07a790939936c0f43ca15048a2a2f976ff334 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Mon, 28 Sep 2026 11:35:32 +0530 Subject: [PATCH 6/6] fix: [editor, prompt-input] address review findings - editorToHTML splits a tag at its content hole instead of a NUL sentinel, so an href cannot inject attributes. The JSON loader and the Markdown writer drop unsafe links. - Pressed toolbar buttons are filled. - The Markdown reader keeps links and code after punctuation as links and code, keeps invalid character references from throwing, and leaves code spans undecoded. The writer escapes text that would read back as other Markdown. - The JSON loader repairs content the schema cannot hold instead of flattening it to text. - A new controlled value starts a new undo history. A controlled Markdown string that the doc already converts to does not reload the doc. - The floating toolbar closes the link field on an outside press and returns focus to the text when a command hides it. - Line breaks survive a change to and from a code block. Shared ProseMirror code moves to core/base.ts, core/html.ts and core/nodes.ts, and the core barrel is removed. Editor tests are trimmed to 50 in one __tests__ folder. The composer markup test moves to prompt-input. --- .../content/docs/components/editor/index.mdx | 26 +- .../content/docs/components/editor/props.ts | 41 +- .../editor/__tests__/bundle.test.ts | 96 ++++ .../components/editor/__tests__/core.test.ts | 242 +++++++++ .../editor/__tests__/data-slots.test.tsx | 26 +- .../editor/__tests__/editor-menus.test.tsx | 202 +++----- .../editor/__tests__/editor-toolbar.test.tsx | 487 +++++------------- .../editor/__tests__/editor.test.tsx | 467 ++++++++--------- .../editor/__tests__/markdown.test.tsx | 292 +++++++++++ .../editor/__tests__/test-utils.tsx | 26 +- .../editor/core/__tests__/core.test.ts | 299 ----------- .../raystack/components/editor/core/base.ts | 40 ++ .../components/editor/core/commands.ts | 58 ++- .../components/editor/core/composer/markup.ts | 5 +- .../components/editor/core/composer/schema.ts | 2 +- .../core/composer/use-composer-editor.ts | 52 +- .../editor/core/editor-core.module.css | 44 +- .../raystack/components/editor/core/html.ts | 10 + .../raystack/components/editor/core/index.ts | 58 --- .../components/editor/core/input-rules.ts | 3 +- .../raystack/components/editor/core/json.ts | 294 ++++++++--- .../components/editor/core/keymaps.ts | 25 +- .../raystack/components/editor/core/link.ts | 37 +- .../components/editor/core/mention.ts | 7 + .../raystack/components/editor/core/nodes.ts | 75 +++ .../raystack/components/editor/core/schema.ts | 81 +-- .../components/editor/core/serializers.ts | 104 ++-- .../editor/core/suggestion-menu.tsx | 13 +- .../components/editor/core/task-item-view.ts | 13 +- .../editor/core/use-suggestion-menu.ts | 34 +- .../components/editor/editor-content.tsx | 20 +- .../components/editor/editor-control.tsx | 71 ++- .../components/editor/editor-defaults.ts | 8 +- .../editor/editor-floating-context.ts | 1 - .../editor/editor-floating-toolbar.tsx | 43 +- .../components/editor/editor-heading-menu.tsx | 66 ++- .../editor/editor-history-button.tsx | 4 +- .../components/editor/editor-link-button.tsx | 67 +-- .../components/editor/editor-list-menu.tsx | 52 +- .../components/editor/editor-mentions.tsx | 41 +- .../components/editor/editor-menu-control.tsx | 89 ++-- .../components/editor/editor-root.tsx | 2 +- .../components/editor/editor-slash-items.tsx | 276 +++++----- .../components/editor/editor-slash-menu.tsx | 38 +- .../components/editor/editor-store.ts | 306 ++++++----- .../components/editor/editor-types.ts | 6 +- .../components/editor/editor.module.css | 58 +-- packages/raystack/components/editor/index.tsx | 2 +- .../editor/markdown/__tests__/bundle.test.ts | 89 ---- .../markdown/__tests__/markdown.test.tsx | 325 ------------ .../editor/markdown/from-markdown.ts | 62 ++- .../components/editor/markdown/to-markdown.ts | 328 +++++++++--- .../__tests__/prompt-input-markup.test.ts} | 8 +- .../prompt-input/prompt-input-editor.tsx | 21 +- .../prompt-input/use-mention-menu.ts | 6 +- packages/raystack/index.tsx | 1 + packages/raystack/vitest.setup.ts | 89 ++-- 57 files changed, 2733 insertions(+), 2505 deletions(-) create mode 100644 packages/raystack/components/editor/__tests__/bundle.test.ts create mode 100644 packages/raystack/components/editor/__tests__/core.test.ts create mode 100644 packages/raystack/components/editor/__tests__/markdown.test.tsx delete mode 100644 packages/raystack/components/editor/core/__tests__/core.test.ts create mode 100644 packages/raystack/components/editor/core/base.ts create mode 100644 packages/raystack/components/editor/core/html.ts delete mode 100644 packages/raystack/components/editor/core/index.ts create mode 100644 packages/raystack/components/editor/core/nodes.ts delete mode 100644 packages/raystack/components/editor/markdown/__tests__/bundle.test.ts delete mode 100644 packages/raystack/components/editor/markdown/__tests__/markdown.test.tsx rename packages/raystack/components/{editor/core/__tests__/markup.test.ts => prompt-input/__tests__/prompt-input-markup.test.ts} (97%) diff --git a/apps/www/src/content/docs/components/editor/index.mdx b/apps/www/src/content/docs/components/editor/index.mdx index 93f04e802..f737f5d1e 100644 --- a/apps/www/src/content/docs/components/editor/index.mdx +++ b/apps/www/src/content/docs/components/editor/index.mdx @@ -105,7 +105,7 @@ 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 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. @@ -123,6 +123,8 @@ A menu that inserts a mention chip. It takes the same props as `PromptInput.Ment `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. + @@ -137,6 +139,8 @@ The second argument of `onValueChange`. The methods convert the doc from that ch `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 @@ -167,7 +171,7 @@ Spread `defaultSlashItems` and add your own. `run` gets the editor API after the ### 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. +`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. @@ -179,7 +183,7 @@ With the `markdown` prop, `value` can be a Markdown string, pasted plain-text Ma ### 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. +`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. @@ -205,17 +209,17 @@ The value is ProseMirror JSON, the output of `doc.toJSON()`. Node and mark names | `horizontalRule` | block | | `---` | | `hardBreak` | inline | | trailing `\` | | `mention` | inline | `id`, `label`, `type`, `trigger` | `@[label](type:id)` | -| `bold`, `italic`, `strike`, `code` | mark | | `**`, `_`, `~~`, `` ` `` | +| `bold`, `italic`, `strike`, `code` | mark | | `**`, `*`, `~~`, `` ` `` | | `underline` | mark | | `` | | `link` | mark | `href` | `[text](href)` | -On load, a node the schema does not have becomes a paragraph with its text, and an unknown mark is dropped. +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 `---` a divider. `**bold**`, `_italic_`, `` `code` `` and `~~strike~~` apply marks. Backspace right after a shortcut undoes it. +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. +`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 | |--------|-----| @@ -232,7 +236,7 @@ Typing shortcuts: `#` to `####` and a space make a heading, `-` or `*` a bullete | Checklist | `Mod-Shift-7` | | Quote | `Alt-Shift-.` | | Code block | `Mod-Shift-\` | -| Undo, redo | `Mod-z`, `Mod-Shift-z` | +| 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. @@ -260,9 +264,9 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test ## Accessibility -- The content is a `textbox` with `aria-multiline`. While a menu is open it also has `aria-expanded`, `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, and Escape in the floating toolbar returns focus to the text. +- 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. +- 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 index 389bb392d..ab020f449 100644 --- a/apps/www/src/content/docs/components/editor/props.ts +++ b/apps/www/src/content/docs/components/editor/props.ts @@ -13,7 +13,8 @@ export interface EditorProps { /** * Fires once per doc change. It does not fire for changes made through - * `value`. The value is always editor JSON. + * `value`, or for labels from `resolveMentions`. The value is always editor + * JSON. */ onValueChange?: (value: EditorJSON, details: EditorChangeDetails) => void; @@ -31,22 +32,7 @@ export interface EditorProps { * Allowlist of nodes and marks. Read once, when the editor is created. * @defaultValue all formats */ - formats?: Array< - | 'bold' - | 'italic' - | 'underline' - | 'strike' - | 'code' - | 'link' - | 'heading' - | 'blockquote' - | 'codeBlock' - | 'bulletList' - | 'orderedList' - | 'taskList' - | 'horizontalRule' - | 'mention' - >; + formats?: EditorFormat[]; /** Overrides a default key in ProseMirror keymap syntax, or turns it off with `false`. Read once. */ shortcuts?: Partial>; @@ -288,13 +274,14 @@ export interface EditorMentionItem { 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 fill it in. */ + /** 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`. */ @@ -309,7 +296,7 @@ export interface EditorApi { commands: EditorCommands; /** Dry runs a command. Nothing is dispatched. */ can: EditorCommands; - isActive: (format: string, attrs?: Record) => boolean; + isActive: (format: EditorFormat, attrs?: Record) => boolean; getJSON: () => EditorJSON; getText: () => string; getHTML: () => string; @@ -380,6 +367,22 @@ export interface MarkdownAdapterOptions { copy?: boolean; } +type EditorFormat = + | 'bold' + | 'italic' + | 'underline' + | 'strike' + | 'code' + | 'link' + | 'heading' + | 'blockquote' + | 'codeBlock' + | 'bulletList' + | 'orderedList' + | 'taskList' + | 'horizontalRule' + | 'mention'; + type EditorAction = | 'bold' | 'italic' 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 index 92e481554..21cbdf1d6 100644 --- a/packages/raystack/components/editor/__tests__/data-slots.test.tsx +++ b/packages/raystack/components/editor/__tests__/data-slots.test.tsx @@ -6,8 +6,6 @@ import { Editor } from '../editor'; import type { EditorApi } from '../editor-types'; import { contentOf, doc, flush, p, paste, select } from './test-utils'; -const PEOPLE = [{ id: 'u1', label: 'Maya Chen' }]; - describe('Editor data-slot contract', () => { it('renders the root, content and fixed toolbar slots', async () => { const { container } = render( @@ -40,7 +38,7 @@ describe('Editor data-slot contract', () => { expectSlots(document.body, ['editor-link-form', 'editor-link-input']); }); - it('renders the floating toolbar slot', async () => { + it('renders the floating toolbar and menu slots while they are open', async () => { const actionsRef = createRef(); const { container } = render( @@ -48,30 +46,24 @@ describe('Editor data-slot contract', () => { + + ); const content = contentOf(container); - act(() => content.focus()); - fireEvent.focus(content); 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']); - }); - it('renders the menu slots while they are open', async () => { - const { container } = render( - - - - - - ); - const content = contentOf(container); - expect(getSlot(document.body, 'editor-slash-menu')).toBeNull(); - paste(content, '/'); + select(view, 6, 6); + paste(content, ' /'); await flush(); expectSlots(document.body, ['editor-slash-menu']); paste(content, ' @'); diff --git a/packages/raystack/components/editor/__tests__/editor-menus.test.tsx b/packages/raystack/components/editor/__tests__/editor-menus.test.tsx index c8765fd80..73cbd709e 100644 --- a/packages/raystack/components/editor/__tests__/editor-menus.test.tsx +++ b/packages/raystack/components/editor/__tests__/editor-menus.test.tsx @@ -5,7 +5,15 @@ 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 } from './test-utils'; +import { + contentOf, + doc, + flush, + p, + paste, + pressKey, + select +} from './test-utils'; const PEOPLE: EditorMentionItem[] = [ { id: 'u1', label: 'Maya Chen', type: 'user', group: 'People' }, @@ -38,138 +46,94 @@ function setup( } describe('Editor.SlashMenu', () => { - it('opens on "/" with the default commands', async () => { + it('opens on "/", sets combobox attributes and filters on keywords', async () => { const { content } = setup(); + expect(content).toHaveAttribute('aria-autocomplete', 'list'); paste(content, '/'); await flush(); - expect(screen.getByRole('listbox', { name: 'Commands' })).toHaveAttribute( - 'data-slot', - 'editor-slash-menu' - ); + const listbox = screen.getByRole('listbox', { name: 'Commands' }); expect(screen.getAllByRole('option')).toHaveLength( defaultSlashItems.length ); - }); - - it('sets combobox attributes on the content while open', async () => { - const { content } = setup(); - expect(content).toHaveAttribute('aria-expanded', 'false'); - paste(content, '/'); - await flush(); - const listbox = screen.getByRole('listbox'); - expect(content).toHaveAttribute('aria-expanded', 'true'); + // `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 ); - }); - - it('filters on labels and keywords', async () => { - const { content } = setup(); - paste(content, '/todo'); + paste(content, 'todo'); await flush(); const options = screen.getAllByRole('option'); expect(options).toHaveLength(1); expect(options[0]).toHaveTextContent('Checklist'); }); - it('runs the highlighted command on Enter and removes the query', async () => { - const { content, api } = setup(); - paste(content, '/code'); - await flush(); - pressKey(content, 'Enter'); - await flush(); - expect(api().getJSON()).toEqual( - doc({ type: 'codeBlock', attrs: { language: null } }) - ); - expect(screen.queryByRole('listbox')).toBeNull(); - }); - - it('restores the query with one undo', async () => { + 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('moves the highlight with the arrow keys', async () => { - const { content } = setup(); - paste(content, '/'); - await flush(); - pressKey(content, 'ArrowDown'); - expect(screen.getAllByRole('option')[1]).toHaveAttribute( - 'aria-selected', - 'true' - ); - }); - - it('closes on Escape and leaves the text', async () => { - const { content, api } = setup(); - paste(content, '/he'); - await flush(); - pressKey(content, 'Escape'); - await flush(); - expect(screen.queryByRole('listbox')).toBeNull(); - expect(api().getText()).toBe('/he'); - }); - - it('closes when the query gets a space', async () => { - const { content } = setup(); - paste(content, '/he'); - await flush(); - paste(content, ' '); - await flush(); - expect(screen.queryByRole('listbox')).toBeNull(); - }); - - it('does not open in the middle of a word', async () => { - const { content } = setup(); - paste(content, 'and/or'); - await flush(); - expect(screen.queryByRole('listbox')).toBeNull(); - }); - - it('does not open inside a code block', async () => { - const { content } = setup(, { - defaultValue: doc({ type: 'codeBlock' }) - }); - paste(content, '/'); - await flush(); - expect(screen.queryByRole('listbox')).toBeNull(); - }); - - it('runs a custom item with the editor api', async () => { + 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: "Today's date", keywords: ['time'], run } + { id: 'date', label: 'Date', description: 'Insert a dated line', run }, + { id: 'other', label: 'Other', run: () => undefined } ]; const { content, api } = setup(); - paste(content, '/time'); + paste(content, '/dated'); await flush(); - fireEvent.click(screen.getByRole('option', { name: /Today's date/ })); + 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', async () => { - const { content } = setup(, { - formats: ['bold', 'bulletList'] - }); + 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', () => { @@ -179,10 +143,6 @@ describe('Editor.Mentions', () => { ); paste(content, '@ma'); await flush(); - expect(screen.getByRole('listbox')).toHaveAttribute( - 'data-slot', - 'editor-mention-menu' - ); pressKey(content, 'Enter'); await flush(); expect(api().getJSON()).toEqual( @@ -196,8 +156,7 @@ describe('Editor.Mentions', () => { ) ) ); - const details = onValueChange.mock.lastCall?.[1]; - expect(details.getMentions()).toEqual([ + expect(onValueChange.mock.lastCall?.[1].getMentions()).toEqual([ { id: 'u1', label: 'Maya Chen', @@ -212,14 +171,6 @@ describe('Editor.Mentions', () => { ); }); - it('groups results', async () => { - const { content } = setup(); - paste(content, '@'); - await flush(); - expect(screen.getByText('People')).toBeInTheDocument(); - expect(screen.getByText('Issues')).toBeInTheDocument(); - }); - it('supports one menu per trigger', async () => { const { content } = setup( <> @@ -233,35 +184,28 @@ describe('Editor.Mentions', () => { expect(screen.getByRole('option')).toHaveTextContent('ENG-214'); }); - it('searches asynchronously', async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - const onSearch = vi.fn(async (query: string) => - PEOPLE.filter(item => item.label.toLowerCase().includes(query)) + 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: '@' } + }) + ) + } ); - const { content } = setup(); - paste(content, '@ar'); - await act(async () => { - await vi.advanceTimersByTimeAsync(200); - }); - expect(onSearch).toHaveBeenCalledWith('ar', expect.anything()); - expect(screen.getByRole('option')).toHaveTextContent('Arjun Rao'); - vi.useRealTimers(); - }); - - it('renders nothing without the mention format', async () => { - const { content } = setup(, { - formats: ['bold'] - }); - paste(content, '@'); await flush(); - expect(screen.queryByRole('listbox')).toBeNull(); - }); - - it('inserts a mention from the api', () => { - const { api } = setup(); - act(() => { - api().commands.insertMention(PEOPLE[1]); - }); - expect(api().getText()).toBe('@Arjun Rao '); + 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 index e44592cd7..1e4a638f8 100644 --- a/packages/raystack/components/editor/__tests__/editor-toolbar.test.tsx +++ b/packages/raystack/components/editor/__tests__/editor-toolbar.test.tsx @@ -1,50 +1,23 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { createRef } from 'react'; import { describe, expect, it } from 'vitest'; -import { Toolbar } from '../../toolbar'; import { Editor } from '../editor'; +import styles from '../editor.module.css'; import type { EditorApi } from '../editor-types'; -import { - contentOf, - doc, - flush, - p, - paste, - pressKey, - select -} from './test-utils'; +import { contentOf, doc, flush, p, pressKey, select } from './test-utils'; -function FixedEditor(props: Partial[0]>) { - return ( - +function setup() { + const actionsRef = createRef(); + const result = render( + - - - - - - - - - ); -} - -function setup(props: Partial[0]> = {}) { - const actionsRef = createRef(); - const result = render( - - ); const api = () => { if (!actionsRef.current) throw new Error('no api'); return actionsRef.current; @@ -54,269 +27,71 @@ function setup(props: Partial[0]> = {}) { if (!current) throw new Error('no view'); return current; }; - return { ...result, api, view, content: contentOf(result.container) }; + return { ...result, api, view }; } describe('Editor.Toolbar', () => { - it('renders a labelled toolbar', () => { - setup(); - const toolbar = screen.getByRole('toolbar', { name: 'Formatting' }); - expect(toolbar).toHaveAttribute('data-slot', 'editor-toolbar'); - }); - - it('does not render when read only', () => { - setup({ readOnly: true }); - expect(screen.queryByRole('toolbar')).toBeNull(); - }); - - it('disables every control when disabled', () => { - setup({ disabled: true }); - expect(screen.getByRole('button', { name: 'Bold' })).toHaveAttribute( - 'aria-disabled', - 'true' - ); - expect(screen.getByRole('button', { name: 'Text style' })).toHaveAttribute( - 'aria-disabled', - 'true' - ); - }); - - describe('MarkButton', () => { - it('toggles the mark and reflects it with aria-pressed', () => { - const { api, view } = setup(); - select(view(), 1, 6); - const bold = screen.getByRole('button', { name: 'Bold' }); - expect(bold).toHaveAttribute('aria-pressed', 'false'); - fireEvent.click(bold); - expect(api().getHTML()).toBe('

Hello

'); - expect(bold).toHaveAttribute('aria-pressed', 'true'); - expect(bold).toHaveAttribute('data-active'); - }); - - it('prevents the mouse down, so the selection stays', () => { - setup(); - const bold = screen.getByRole('button', { name: 'Bold' }); - const event = new MouseEvent('mousedown', { - bubbles: true, - cancelable: true - }); - bold.dispatchEvent(event); - expect(event.defaultPrevented).toBe(true); - }); - - it('is disabled inside a code block', () => { - const { view } = setup({ - defaultValue: doc({ - type: 'codeBlock', - content: [{ type: 'text', text: 'x' }] - }) - }); - select(view(), 1, 2); - expect(screen.getByRole('button', { name: 'Bold' })).toHaveAttribute( - 'aria-disabled', - 'true' - ); - }); - - it('renders nothing for a mark outside formats', () => { - setup({ formats: ['italic'] }); - expect(screen.queryByRole('button', { name: 'Bold' })).toBeNull(); - expect( - screen.getByRole('button', { name: 'Italic' }) - ).toBeInTheDocument(); - }); - - it('shows the label and shortcut in a tooltip', async () => { - setup(); - const bold = screen.getByRole('button', { name: 'Bold' }); - fireEvent.focus(bold); - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 300)); - }); - const tooltip = document.querySelector('[data-slot="tooltip-content"]'); - expect(tooltip).toHaveTextContent('Bold'); - expect(tooltip).toHaveTextContent('CtrlB'); - }); - }); - - describe('BlockButton', () => { - it('toggles a quote', () => { - const { api, view } = setup(); - select(view(), 1, 1); - fireEvent.click(screen.getByRole('button', { name: 'Quote' })); - expect(api().getHTML()).toBe('

Hello

'); - expect(screen.getByRole('button', { name: 'Quote' })).toHaveAttribute( - 'aria-pressed', - 'true' - ); - fireEvent.click(screen.getByRole('button', { name: 'Quote' })); - expect(api().getHTML()).toBe('

Hello

'); - }); - - it('toggles a code block', () => { - const { api, view } = setup(); - select(view(), 1, 1); - fireEvent.click(screen.getByRole('button', { name: 'Code block' })); - expect(api().getHTML()).toBe('
Hello
'); - }); - - it('inserts a divider without a pressed state', () => { - const { api, view } = setup(); - select(view(), 6, 6); - const divider = screen.getByRole('button', { name: 'Divider' }); - expect(divider).not.toHaveAttribute('aria-pressed'); - fireEvent.click(divider); - expect(api().getHTML()).toBe('

Hello


'); - }); - }); - - describe('HistoryButton', () => { - it('is disabled until there is something to undo', () => { - const { content } = setup(); - const undo = screen.getByRole('button', { name: 'Undo' }); - expect(undo).toHaveAttribute('aria-disabled', 'true'); - paste(content, '!'); - expect(undo).toHaveAttribute('aria-disabled', 'false'); - fireEvent.click(undo); - expect(content).toHaveTextContent(/^Hello$/); - expect(screen.getByRole('button', { name: 'Redo' })).toHaveAttribute( - 'aria-disabled', - 'false' - ); - }); - }); - - describe('HeadingMenu', () => { - 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(trigger).toBeInTheDocument(); - const item = await screen.findByRole('menuitemradio', { - name: /Heading 2/ - }); - fireEvent.click(item); - await flush(); - expect(api().getHTML()).toBe('

Hello

'); - }); - - it('marks the current style as checked', async () => { - const { view } = setup(); - select(view(), 1, 1); - fireEvent.click(screen.getByRole('button', { name: 'Text style' })); - await flush(); - expect( - await screen.findByRole('menuitemradio', { name: /Text/ }) - ).toHaveAttribute('aria-checked', 'true'); - expect( - screen.getByRole('menuitemradio', { name: /Heading 1/ }) - ).toHaveAttribute('aria-checked', 'false'); - }); - - it('lists only the given levels', async () => { - render( - - - - - - - ); - fireEvent.click(screen.getByRole('button', { name: 'Text style' })); - await flush(); - expect(await screen.findAllByRole('menuitemradio')).toHaveLength(3); - }); - }); - - describe('ListMenu', () => { - it('turns the paragraph into a checklist', async () => { - const { api, view } = setup(); - select(view(), 1, 1); - fireEvent.click(screen.getByRole('button', { name: 'List' })); - await flush(); - fireEvent.click( - await screen.findByRole('menuitemradio', { name: /Checklist/ }) - ); - await flush(); - expect(api().isActive('taskList')).toBe(true); - }); - }); - - describe('LinkButton', () => { - it('opens a URL field and applies the link', 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"'); - }); - - it('opens with Mod-k', async () => { - const { content, view } = setup(); - select(view(), 1, 6); - pressKey(content, 'k', { ctrlKey: true }); - await flush(); - expect( - await screen.findByRole('textbox', { name: 'Link URL' }) - ).toBeInTheDocument(); - }); - - it('removes a link', async () => { - const { api, view } = setup({ - defaultValue: doc({ - type: 'paragraph', - content: [ - { - type: 'text', - text: 'Hello', - marks: [{ type: 'link', attrs: { href: 'https://a.b' } }] - } - ] - }) - }); - select(view(), 2, 2); - fireEvent.click(screen.getByRole('button', { name: 'Link' })); - await flush(); - fireEvent.click( - await screen.findByRole('button', { name: 'Remove link' }) - ); - await flush(); - expect(api().getHTML()).toBe('

Hello

'); - }); + 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('moves focus into the toolbar with Alt-F10', () => { - const { content } = setup(); - pressKey(content, 'F10', { altKey: true }); - expect(document.activeElement).toBe( - screen.getByRole('button', { name: 'Text style' }) - ); + 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(props: Partial[0]> = {}) { + function setupFloating() { const actionsRef = createRef(); const result = render( - - - - - - - +
+ + + + + + + + + +
); const content = contentOf(result.container); const view = () => { @@ -324,99 +99,117 @@ describe('Editor.FloatingToolbar', () => { if (!current) throw new Error('no view'); return current; }; - const focus = () => { + // Focuses the text and selects "Hello". + const selectWord = async () => { act(() => content.focus()); fireEvent.focus(content); + select(view(), 1, 6); + await flush(); }; - return { ...result, content, view, focus, api: () => actionsRef.current }; + 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 while the editor has focus', async () => { - const { view, focus } = setupFloating(); - focus(); + it('shows for a text selection and hides when it collapses', async () => { + const { view, selectWord } = setupFloating(); expect(screen.queryByRole('toolbar')).toBeNull(); - select(view(), 1, 6); - await flush(); - const toolbar = screen.getByRole('toolbar', { name: 'Formatting' }); - expect(toolbar).toHaveAttribute('data-slot', 'editor-floating-toolbar'); - }); - - it('hides when the selection collapses', async () => { - const { view, focus } = setupFloating(); - focus(); - select(view(), 1, 6); - await flush(); + 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 { view, focus, content } = setupFloating(); - focus(); + const { content, selectWord } = setupFloating(); fireEvent.mouseDown(content); - select(view(), 1, 6); - await flush(); + await selectWord(); expect(screen.queryByRole('toolbar')).toBeNull(); fireEvent.mouseUp(document); await flush(); expect(screen.getByRole('toolbar')).toBeInTheDocument(); }); - it('toggles a mark from the floating toolbar', async () => { - const { view, focus, api } = setupFloating(); - focus(); + 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(); - fireEvent.click(screen.getByRole('button', { name: 'Bold' })); - expect(api()?.getHTML()).toBe('

Hello world

'); + expect(screen.getByRole('toolbar')).toBeInTheDocument(); }); - it('swaps the buttons for the link field', async () => { - const { view, focus, api } = setupFloating(); - focus(); - select(view(), 1, 6); - await flush(); - fireEvent.click(screen.getByRole('button', { name: 'Link' })); + 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(); - const input = screen.getByRole('textbox', { name: 'Link URL' }); + 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(api()?.getHTML()).toContain('href="https://raystack.org"'); - }); + expect(screen.getByRole('toolbar')).toBeInTheDocument(); - it('does not show inside a code block', async () => { - const { view, focus } = setupFloating({ - defaultValue: doc({ - type: 'codeBlock', - content: [{ type: 'text', text: 'const a = 1' }] - }) - }); - focus(); - select(view(), 1, 6); + await selectWord(); + pressKey(content, 'k', { ctrlKey: true }); await flush(); - expect(screen.queryByRole('toolbar')).toBeNull(); - }); - - it('respects shouldShow', async () => { - const actionsRef = createRef(); - const { container } = render( - - - false}> - - - + 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 ); - const content = contentOf(container); - act(() => content.focus()); - fireEvent.focus(content); - const view = actionsRef.current?.view; - if (!view) throw new Error('no view'); - select(view, 1, 6); + if (!guard) throw new Error('no focus guard after the popup'); + act(() => guard.focus()); await flush(); - expect(screen.queryByRole('toolbar')).toBeNull(); + 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 index 269007e5f..02c04147c 100644 --- a/packages/raystack/components/editor/__tests__/editor.test.tsx +++ b/packages/raystack/components/editor/__tests__/editor.test.tsx @@ -4,6 +4,7 @@ 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, @@ -42,291 +43,245 @@ function setup(props: Partial[0]> = {}) { } describe('Editor', () => { - describe('Rendering', () => { - it('renders the root and an editable textbox', () => { - const { container, content } = setup(); - const root = container.querySelector('[data-slot="editor"]'); - expect(root).toHaveClass(styles.root); - expect(content).toHaveAttribute('role', 'textbox'); - expect(content).toHaveAttribute('aria-multiline', 'true'); - expect(content).toHaveAttribute('contenteditable', 'true'); - expect(screen.getByRole('textbox', { name: 'Description' })).toBe( - content - ); - }); - - it('applies className to the root and the content', () => { - const { container } = render( - - - - ); - expect(container.querySelector('[data-slot="editor"]')).toHaveClass( - 'root-class' - ); - expect(contentOf(container)).toHaveClass('content-class'); - }); - - it('renders defaultValue', () => { - const { content } = setup({ - defaultValue: doc( - { - type: 'heading', - attrs: { level: 2 }, - content: [{ type: 'text', text: 'Title' }] - }, - p('Body') - ) - }); - expect(content.querySelector('h2')).toHaveTextContent('Title'); - expect(content.querySelector('p')).toHaveTextContent('Body'); - }); - - it('marks the root as empty until there is text', () => { - const { container, content } = setup(); - const root = container.querySelector('[data-slot="editor"]'); - expect(root).toHaveAttribute('data-empty'); - paste(content, 'Hello'); - expect(root).not.toHaveAttribute('data-empty'); - }); + 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('shows the placeholder on an empty doc', () => { - const { content } = setup({ placeholder: 'Add description…' }); - expect( - content.querySelector('[data-placeholder="Add description…"]') - ).toBeInTheDocument(); - paste(content, 'x'); - 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); }); - describe('Value', () => { - 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(details.getMentions()).toEqual([]); - 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('applies a controlled value without reporting it', () => { - const onValueChange = vi.fn(); - const { container, rerender } = render( - - - - ); - rerender( - + it('keeps the caret when the controlled value is the one it emitted', () => { + function Controlled() { + const [value, setValue] = useState(doc(p())); + return ( + ); - expect(contentOf(container)).toHaveTextContent('Two'); - 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('loads unknown nodes as text', () => { - const { api } = setup({ - formats: ['bold'], - defaultValue: doc( - { - type: 'heading', - attrs: { level: 1 }, - content: [{ type: 'text', text: 'Title' }] - }, - { - type: 'paragraph', - content: [{ type: 'text', text: 'a', marks: [{ type: 'italic' }] }] - } - ) - }); - expect(api().getJSON()).toEqual(doc(p('Title'), p('a'))); - }); + } + const { container } = render(); + const content = contentOf(container); + paste(content, 'Hello'); + paste(content, ' world'); + expect(content).toHaveTextContent('Hello world'); }); - describe('Shortcuts', () => { - it('toggles bold with Mod-b', () => { - const { content, view, api } = setup({ defaultValue: doc(p('Hello')) }); - select(view(), 1, 6); - pressKey(content, 'b', { ctrlKey: true }); - expect(api().isActive('bold')).toBe(true); - expect(api().getHTML()).toBe('

Hello

'); + 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(); - it('sets a heading with Mod-Alt-2', () => { - const { content, view, api } = setup({ defaultValue: doc(p('Hello')) }); - select(view(), 1, 1); - pressKey(content, '™', { ctrlKey: true, altKey: true, keyCode: 50 }); - expect(api().getHTML()).toBe('

Hello

'); + const off = setup({ + defaultValue: doc(p('Hello')), + shortcuts: { bold: false, redo: false } }); - - it('turns a shortcut off with false', () => { - const { content, view, api } = setup({ - defaultValue: doc(p('Hello')), - shortcuts: { bold: false } - }); - select(view(), 1, 6); - pressKey(content, 'b', { ctrlKey: true }); - expect(api().isActive('bold')).toBe(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); }); - describe('Input rules', () => { - it.each([ + it('applies input rules, and waits for a space after ***', () => { + const cases = [ ['# ', '

'], - ['### ', '

'], ['- ', '
'], - ['1. ', '
'], - ['> ', '

'], - ['```', '

```

'] - ])('turns %j into a block', (input, html) => { - const { view, api } = setup(); + ['*** ', '

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

***bold

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

bold

'] + ]; + for (const [input, html] of cases) { + const { view, api, unmount } = setup(); typeText(view(), input); - expect(api().getHTML()).toBe(html); - }); - - it('turns "[] " into a checklist', () => { - const { view, api } = setup(); - typeText(view(), '[] '); - expect(api().getJSON()).toEqual( - doc({ - type: 'taskList', - content: [ - { type: 'taskItem', attrs: { checked: false }, content: [p()] } - ] - }) - ); - }); - - it.each([ - ['**bold**', '

bold

'], - ['_it_', '

it

'], - ['`code`', '

code

'], - ['~~gone~~', '

gone

'] - ])('turns %j into a mark', (input, html) => { - const { view, api } = setup(); - typeText(view(), input); - expect(api().getHTML()).toBe(html); - }); + expect(api().getHTML(), input).toBe(html); + unmount(); + } + }); - it('skips rules for formats that are not allowed', () => { - const { view, api } = setup({ formats: ['bold'] }); - typeText(view(), '# '); - expect(api().getHTML()).toBe('

#

'); - }); + 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)

' + ); }); - describe('States', () => { - it('is not editable when disabled', () => { - const { container, content } = setup({ disabled: true }); - expect(container.querySelector('[data-slot="editor"]')).toHaveAttribute( - 'data-disabled' - ); - expect(content).toHaveAttribute('contenteditable', 'false'); - expect(content).toHaveAttribute('aria-disabled', 'true'); + 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(); - it('is not editable when read only', () => { - const { container, content } = setup({ readOnly: true }); - expect(container.querySelector('[data-slot="editor"]')).toHaveAttribute( - 'data-readonly' - ); - expect(content).toHaveAttribute('contenteditable', 'false'); - expect(content).toHaveAttribute('aria-readonly', 'true'); - }); + rerender( + + + + ); + expect(root).toHaveAttribute('data-readonly'); + expect(content).toHaveAttribute('contenteditable', 'false'); + expect(content).toHaveAttribute('aria-readonly', 'true'); + expect(checkbox).toBeDisabled(); - it('tracks focus on the root', () => { - const { container, content } = setup(); - act(() => content.focus()); - fireEvent.focus(content); - expect(container.querySelector('[data-slot="editor"]')).toHaveAttribute( - 'data-focused' - ); - }); - }); + rerender( + + + + ); + expect(root).toHaveAttribute('data-disabled'); + expect(content).toHaveAttribute('aria-disabled', 'true'); + expect(checkbox).toBeDisabled(); - describe('API', () => { - it('runs commands and dry runs', () => { - const { api, view } = 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); - api().commands.toggleList('orderedList'); - expect(api().getHTML()).toBe('
  1. Item

'); - api().commands.toggleList('orderedList'); - expect(api().getHTML()).toBe('

Item

'); - }); + rerender( + + + + ); + expect(content).toHaveAttribute('contenteditable', 'true'); + expect(checkbox).toBeEnabled(); + }); - it('sets and removes a link', () => { - const { api, view } = setup({ defaultValue: doc(p('Apsara')) }); - select(view(), 1, 7); - expect(api().commands.setLink('raystack.org')).toBe(true); - expect(api().getHTML()).toBe( - '

Apsara

' - ); - expect(api().commands.setLink('javascript:alert(1)')).toBe(false); - api().commands.unsetLink(); - expect(api().getHTML()).toBe('

Apsara

'); - }); + 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('returns null from getMarkdown without the markdown prop', () => { - const error = vi - .spyOn(console, 'error') - .mockImplementation(() => undefined); - const { api } = setup(); - expect(api().getMarkdown()).toBeNull(); - expect(error).toHaveBeenCalled(); - error.mockRestore(); + it('runs commands and dry runs from the api', () => { + const { api, view, onValueChange } = setup({ + defaultValue: doc(p('Item')) }); - - it('sets and clears content, and reports it', () => { - const { api, onValueChange } = setup(); - act(() => { - api().commands.setContent(doc(p('New'))); - }); - expect(api().getText()).toBe('New'); - expect(onValueChange).toHaveBeenCalledTimes(1); - act(() => { - api().commands.clear(); - }); - expect(api().getJSON()).toEqual(doc(p())); + 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('undoes and redoes', () => { - const { api, content } = setup(); - paste(content, 'Hello'); - act(() => { - api().commands.undo(); - }); - expect(api().getText()).toBe(''); - act(() => { - api().commands.redo(); - }); - expect(api().getText()).toBe('Hello'); - }); + 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 index 46ff788d0..63f698033 100644 --- a/packages/raystack/components/editor/__tests__/test-utils.tsx +++ b/packages/raystack/components/editor/__tests__/test-utils.tsx @@ -1,29 +1,43 @@ 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 = container.querySelector('[data-slot="editor-content"]'); + const node = getSlot(container, 'editor-content'); if (!node) throw new Error('editor content not found'); - return node as HTMLElement; + 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, html?: string) { +export function paste(element: HTMLElement, text: string) { fireEvent.paste(element, { clipboardData: { - types: html ? ['text/plain', 'text/html'] : ['text/plain'], + types: ['text/plain'], files: [], - getData: (kind: string) => - kind === 'text/plain' ? text : kind === 'text/html' ? (html ?? '') : '' + 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) { diff --git a/packages/raystack/components/editor/core/__tests__/core.test.ts b/packages/raystack/components/editor/core/__tests__/core.test.ts deleted file mode 100644 index 8497f8eac..000000000 --- a/packages/raystack/components/editor/core/__tests__/core.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { EditorState, TextSelection } from 'prosemirror-state'; -import { describe, expect, it } from 'vitest'; -import { clearFormatting, insertHorizontalRule, toggleList } from '../commands'; -import { docFromJSON, type EditorJSON } from '../json'; -import { isSafeHref, normalizeHref } from '../link'; -import { buildSchema } from '../schema'; -import { - docToText, - editorToHTML, - editorToText, - isEditorEmpty -} from '../serializers'; -import { formatShortcut } from '../shortcuts'; - -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 -}); - -function stateOf(json: EditorJSON, from: number, to = from) { - const schema = buildSchema(); - const docNode = docFromJSON(schema, json); - return EditorState.create({ - doc: docNode, - selection: TextSelection.create(docNode, from, to) - }); -} - -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, merging adjacent runs', () => { - expect( - editorToHTML( - doc( - paragraph( - text('a', [{ type: 'bold' }]), - text('b', [{ type: 'bold' }, { type: 'italic' }]), - text('c') - ) - ) - ) - ).toBe('

abc

'); - }); - - it('writes nested content holes', () => { - expect( - editorToHTML( - doc({ - type: 'codeBlock', - attrs: { language: 'ts' }, - content: [text('const a = 1;')] - }) - ) - ).toBe('
const a = 1;
'); - }); - - it('writes task items with a checkbox', () => { - expect( - editorToHTML( - doc({ - type: 'taskList', - content: [ - { - type: 'taskItem', - attrs: { checked: true }, - content: [paragraph(text('Docs'))] - } - ] - }) - ) - ).toBe( - '
  • Docs

' - ); - }); - - it('escapes text and attributes', () => { - expect(editorToHTML(doc(paragraph(text(' & "x"'))))).toBe( - '

<b> & "x"

' - ); - }); - - it('drops links with an unsafe href', () => { - expect( - editorToHTML( - doc( - paragraph( - text('x', [ - { type: 'link', attrs: { href: 'javascript:alert(1)' } } - ]) - ) - ) - ) - ).toBe('

x

'); - }); - - it('writes mentions with their data attributes', () => { - expect( - editorToHTML( - doc( - paragraph({ - type: 'mention', - attrs: { id: 'u1', label: 'Maya', type: 'user', trigger: '@' } - }) - ) - ) - ).toBe( - '

@Maya

' - ); - }); -}); - -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'); - const schema = buildSchema(); - expect(docToText(docFromJSON(schema, value)).mentions).toEqual([ - { - id: 'u1', - label: 'Maya', - type: 'user', - trigger: '@', - start: 11, - end: 16 - } - ]); - }); -}); - -describe('docFromJSON', () => { - const schema = buildSchema(['bold']); - - it('turns unknown blocks into paragraphs and drops unknown marks', () => { - const loaded = docFromJSON( - schema, - doc( - { type: 'table', content: [paragraph(text('cell'))] }, - paragraph(text('x', [{ type: 'italic' }, { type: 'bold' }])) - ) - ); - expect(loaded.toJSON()).toEqual( - doc(paragraph(text('cell')), paragraph(text('x', [{ type: 'bold' }]))) - ); - }); - - it('turns an unknown inline node into its text', () => { - const loaded = docFromJSON( - schema, - doc( - paragraph({ - type: 'mention', - attrs: { id: 'u1', label: 'Maya', type: 'user', trigger: '@' } - }) - ) - ); - expect(loaded.toJSON()).toEqual(doc(paragraph(text('@Maya')))); - }); - - it('returns an empty doc for empty or invalid input', () => { - expect(docFromJSON(schema, doc()).toJSON()).toEqual(doc(paragraph())); - expect( - docFromJSON(schema, { type: 'paragraph' } as EditorJSON).toJSON() - ).toEqual(doc(paragraph())); - }); - - it('clamps heading levels to 4', () => { - const loaded = docFromJSON( - buildSchema(), - doc({ type: 'heading', attrs: { level: 6 }, content: [text('h')] }) - ); - expect(loaded.firstChild?.attrs.level).toBe(4); - }); -}); - -describe('isEditorEmpty', () => { - it('treats whitespace as empty', () => { - const schema = buildSchema(); - expect(isEditorEmpty(docFromJSON(schema, doc(paragraph())))).toBe(true); - expect(isEditorEmpty(docFromJSON(schema, doc(paragraph(text(' ')))))).toBe( - true - ); - expect(isEditorEmpty(docFromJSON(schema, doc(paragraph(text('a')))))).toBe( - false - ); - }); -}); - -describe('commands', () => { - it('converts a bullet list to a checklist in place', () => { - const state = stateOf( - doc({ - type: 'bulletList', - content: [{ type: 'listItem', content: [paragraph(text('a'))] }] - }), - 3 - ); - const { ran, json } = apply(state, toggleList('taskList')); - expect(ran).toBe(true); - expect(json).toEqual( - doc({ - type: 'taskList', - content: [ - { - type: 'taskItem', - attrs: { checked: false }, - content: [paragraph(text('a'))] - } - ] - }) - ); - }); - - it('splits a paragraph around a divider', () => { - const state = stateOf(doc(paragraph(text('abcd'))), 3); - const { json } = apply(state, insertHorizontalRule); - expect(json).toEqual( - doc( - paragraph(text('ab')), - { type: 'horizontalRule' }, - paragraph(text('cd')) - ) - ); - }); - - it('clears marks and reports when there is nothing to clear', () => { - const marked = stateOf( - doc(paragraph(text('ab', [{ type: 'bold' }]))), - 1, - 3 - ); - expect(apply(marked, clearFormatting).json).toEqual( - doc(paragraph(text('ab'))) - ); - const plain = stateOf(doc(paragraph(text('ab'))), 1, 3); - expect(clearFormatting(plain)).toBe(false); - }); -}); - -describe('formatShortcut', () => { - it('uses symbols on macOS', () => { - expect(formatShortcut('Mod-Shift-x', true)).toEqual(['⌘', '⇧', 'X']); - expect(formatShortcut('Alt-F10', true)).toEqual(['⌥', 'F10']); - }); - - it('uses names elsewhere', () => { - expect(formatShortcut('Mod-Alt-1', false)).toEqual(['Ctrl', 'Alt', '1']); - }); - - it('reads a trailing dash as the minus key', () => { - expect(formatShortcut('Mod--', false)).toEqual(['Ctrl', '-']); - }); -}); - -describe('links', () => { - it('allows web, mail and relative links only', () => { - expect(isSafeHref('https://a.b')).toBe(true); - expect(isSafeHref('mailto:a@b.c')).toBe(true); - expect(isSafeHref('/docs')).toBe(true); - expect(isSafeHref('javascript:alert(1)')).toBe(false); - expect(isSafeHref(' java\tscript:alert(1)')).toBe(false); - expect(isSafeHref('data:text/html,x')).toBe(false); - }); - - it('adds a scheme to bare domains and emails', () => { - expect(normalizeHref('raystack.org')).toBe('https://raystack.org'); - expect(normalizeHref('a@b.co')).toBe('mailto:a@b.co'); - expect(normalizeHref('#top')).toBe('#top'); - }); -}); 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 index 7f24d31f3..e550657b4 100644 --- a/packages/raystack/components/editor/core/commands.ts +++ b/packages/raystack/components/editor/core/commands.ts @@ -1,5 +1,5 @@ import { setBlockType, toggleMark, wrapIn } from 'prosemirror-commands'; -import { redo, redoDepth, undo, undoDepth } from 'prosemirror-history'; +import { redo, undo } from 'prosemirror-history'; import type { Mark, MarkType, Node as PMNode } from 'prosemirror-model'; import { liftListItem, wrapInList } from 'prosemirror-schema-list'; import { @@ -10,7 +10,12 @@ import { } from 'prosemirror-state'; import { liftTarget } from 'prosemirror-transform'; import { isSafeHref, normalizeHref } from './link'; -import type { EditorHeadingLevel, EditorList, EditorMark } from './schema'; +import { + type EditorHeadingLevel, + type EditorList, + type EditorMark, + LIST_TYPES +} from './schema'; export type EditorBlock = | 'blockquote' @@ -18,7 +23,7 @@ export type EditorBlock = | EditorList | 'horizontalRule'; -const LISTS: readonly string[] = ['bulletList', 'orderedList', 'taskList']; +const LISTS: readonly string[] = LIST_TYPES; /** Runs `build` on a scratch transaction, so a step that throws reads as "cannot run". */ function tryCommand( @@ -220,14 +225,35 @@ export function toggleList(list: EditorList): Command { }; } +/** + * 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) return false; + if (!type || !canSetLink(state)) return false; const normalized = normalizeHref(href); if (!normalized || !isSafeHref(normalized)) return false; - const { from, to, empty, $from } = state.selection; - if (!$from.parent.type.allowsMarkType(type)) return false; + const { from, to, empty } = state.selection; return tryCommand(state, dispatch, tr => { const mark = type.create({ href: normalized }); @@ -320,8 +346,18 @@ export const clearFormatting: Command = (state, dispatch) => { return true; }; -export const undoCommand: Command = (state, dispatch) => - undoDepth(state) > 0 && undo(state, dispatch); - -export const redoCommand: Command = (state, dispatch) => - redoDepth(state) > 0 && redo(state, dispatch); +/** 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/core/composer/markup.ts b/packages/raystack/components/editor/core/composer/markup.ts index 004e0c0a5..42a493240 100644 --- a/packages/raystack/components/editor/core/composer/markup.ts +++ b/packages/raystack/components/editor/core/composer/markup.ts @@ -3,6 +3,7 @@ import { type EditorMention, isTriggerCharacter, type MentionAttrs, + mentionText, readMention, serializeMention } from '../mention'; @@ -99,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, @@ -137,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 index 80810be5f..b88be844f 100644 --- a/packages/raystack/components/editor/core/composer/schema.ts +++ b/packages/raystack/components/editor/core/composer/schema.ts @@ -3,7 +3,7 @@ import { hardBreakNodeSpec, mentionNodeSpec, paragraphNodeSpec -} from '../schema'; +} from '../nodes'; /** * The PromptInput composer: one paragraph of text, hard breaks, and atomic diff --git a/packages/raystack/components/editor/core/composer/use-composer-editor.ts b/packages/raystack/components/editor/core/composer/use-composer-editor.ts index 1b6377a07..ae41d1bae 100644 --- a/packages/raystack/components/editor/core/composer/use-composer-editor.ts +++ b/packages/raystack/components/editor/core/composer/use-composer-editor.ts @@ -4,15 +4,11 @@ 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 -} 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-core.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 { @@ -37,10 +33,7 @@ import { textFromFragment, textLength } from './markup'; -import { hardBreakType, mentionType } from './schema'; - -/** Marks transactions that came from outside the editor, so they are not echoed back. */ -const EXTERNAL = 'apsara-editor-external'; +import { mentionType } from './schema'; export interface UseComposerEditorOptions { /** Markup for the first document. Read once. */ @@ -96,22 +89,6 @@ export interface UseComposerEditorResult { actions: ComposerEditorActions; } -function escapeHtml(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>'); -} - -const insertHardBreak: Command = (state, dispatch) => { - if (dispatch) { - dispatch( - state.tr.replaceSelectionWith(hardBreakType.create()).scrollIntoView() - ); - } - return true; -}; - export function useComposerEditor( options: UseComposerEditorOptions ): UseComposerEditorResult { @@ -127,7 +104,7 @@ export function useComposerEditor( const initialMarkupRef = useRef(options.initialMarkup); const initialHtml = useMemo( () => ({ - __html: escapeHtml(serializeText(docFromMarkup(initialMarkupRef.current))) + __html: escapeText(serializeText(docFromMarkup(initialMarkupRef.current))) }), [] ); @@ -176,21 +153,6 @@ export function useComposerEditor( } }); - 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), @@ -302,7 +264,7 @@ export function useComposerEditor( keymap(baseKeymap), maxLengthPlugin, scrollPlugin, - placeholderPlugin, + placeholderPlugin(() => optionsRef.current.placeholder, isDocEmpty), clipboardPlugin, focusPlugin ] diff --git a/packages/raystack/components/editor/core/editor-core.module.css b/packages/raystack/components/editor/core/editor-core.module.css index 03d6e10fe..5b5024889 100644 --- a/packages/raystack/components/editor/core/editor-core.module.css +++ b/packages/raystack/components/editor/core/editor-core.module.css @@ -1,30 +1,35 @@ -/* 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. */ +/* 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 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. */ + 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; - width: 100%; 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); - line-height: var(--editor-line-height); letter-spacing: var(--rs-letter-spacing-small); } -.editor > p { +.composer > p { margin: 0; } @@ -32,7 +37,7 @@ 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 paragraph that ends +/* 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 @@ -53,8 +58,8 @@ background: transparent; } -/* Read from the same emptiness predicate as `data-empty`, a ProseMirror-empty - paragraph still holds a trailing
, so `:empty` would never match. */ +/* A decoration, since a ProseMirror-empty paragraph still holds a trailing +
, so `:empty` would never match. */ .placeholder::before { content: attr(data-placeholder); float: left; @@ -132,7 +137,7 @@ outline-offset: 1px; } -.editor[data-disabled] .mention { +.editor[aria-disabled="true"] .mention { opacity: 0.7; } @@ -166,6 +171,19 @@ 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); 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/index.ts b/packages/raystack/components/editor/core/index.ts deleted file mode 100644 index e8b85ceca..000000000 --- a/packages/raystack/components/editor/core/index.ts +++ /dev/null @@ -1,58 +0,0 @@ -export { - deriveDocDetails, - docFromMarkup, - docFromText, - type EditorDocDetails, - isDocEmpty, - serializeMarkup, - serializeText -} from './composer/markup'; -export { composerSchema } from './composer/schema'; -export { - type ComposerEditorActions, - type UseComposerEditorOptions, - type UseComposerEditorResult, - useComposerEditor -} from './composer/use-composer-editor'; -export { default as editorStyles } from './editor-core.module.css'; -// 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 { - type EditorMentionItem, - type EditorMentionRef, - MentionRegistry, - type MentionsData -} from './mention-registry'; -export { - type SuggestionAnchor, - type SuggestionGroup, - SuggestionMenu, - type SuggestionMenuItem, - type SuggestionMenuProps, - suggestionOptionId -} from './suggestion-menu'; -export { - dismissSuggestion, - insertMention, - type SuggestionState, - type SuggestionTrigger -} from './suggestion-plugin'; -export { - filterItems, - type SuggestionItem, - toGroups, - type UseSuggestionMenuOptions, - type UseSuggestionMenuResult, - useMentionRegistryVersion, - useMentionResolution, - useSuggestionMenu -} from './use-suggestion-menu'; diff --git a/packages/raystack/components/editor/core/input-rules.ts b/packages/raystack/components/editor/core/input-rules.ts index 1a1a09ebd..85b136cd1 100644 --- a/packages/raystack/components/editor/core/input-rules.ts +++ b/packages/raystack/components/editor/core/input-rules.ts @@ -92,7 +92,8 @@ export function buildInputRules(schema: Schema): Plugin | null { if (nodes.horizontalRule) { const rule = nodes.horizontalRule; rules.push( - new InputRule(/^(?:---|___|\*\*\*)$/, (state, _match, start, end) => { + // `***` 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; diff --git a/packages/raystack/components/editor/core/json.ts b/packages/raystack/components/editor/core/json.ts index f386e5bed..8fc5feee2 100644 --- a/packages/raystack/components/editor/core/json.ts +++ b/packages/raystack/components/editor/core/json.ts @@ -1,5 +1,12 @@ -import type { Node as PMNode, Schema } from 'prosemirror-model'; -import { isSafeHref } from './link'; +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 { @@ -16,110 +23,243 @@ export function emptyDoc(schema: Schema): PMNode { return doc; } -/** The text inside a node, with mentions written as `@label`. */ -function textOf(json: EditorJSON): string { +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 as string | undefined) ?? '@'; - return `${trigger}${(json.attrs?.label as string | undefined) ?? ''}`; + const trigger = json.attrs?.trigger; + return mentionText({ + trigger: typeof trigger === 'string' ? trigger : '@', + label: String(json.attrs?.label ?? '') + }); } - if (json.type === 'hardBreak') return '\n'; - return (json.content ?? []).map(textOf).join(''); + return ''; } -function textNode(text: string): EditorJSON[] { - return text ? [{ type: 'text', text }] : []; -} - -function sanitizeMarks( +/** + * 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'] -): EditorJSON['marks'] { - if (!marks) return undefined; - const kept = marks.filter(mark => { - if (!(mark.type in schema.marks)) return false; - if (mark.type === 'link') { - const href = mark.attrs?.href; - return typeof href === 'string' && isSafeHref(href); +): 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) }; } - return true; - }); - return kept.length ? kept : undefined; + try { + set = type.create(attrs).addToSet(set); + } catch { + // Attributes the mark cannot take. + } + } + return set; } /** - * Rewrites JSON so the schema can load it: a node type the schema does not - * have becomes its text, as a paragraph at block level. An unknown mark is - * dropped, and so is a link with an unsafe `href`. + * 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 sanitize( +function inlineNodes( schema: Schema, - json: EditorJSON, - inline: boolean -): EditorJSON[] { - if (json.type === 'text') { - if (!json.text) return []; - return [{ ...json, marks: sanitizeMarks(schema, json.marks) }]; + 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; +} - const type = schema.nodes[json.type]; - if (!type) { - const text = textOf(json); - if (inline) return textNode(text); - return [{ type: 'paragraph', content: textNode(text) }]; +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 attrs = - json.type === 'heading' - ? { - ...json.attrs, - level: Math.min(Math.max(Number(json.attrs?.level) || 1, 1), 4) - } - : json.attrs; - - return [ - { - ...json, - attrs, - marks: sanitizeMarks(schema, json.marks), - content: json.content?.flatMap(child => - sanitize(schema, child, type.inlineContent) - ) + 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; } -/** One paragraph per text block, for JSON that still does not fit the schema. */ -function fallbackDoc(schema: Schema, json: EditorJSON): PMNode { - const blocks: string[] = []; - const walk = (node: EditorJSON) => { - const type = schema.nodes[node.type]; - if (type?.isTextblock || (!type && !node.content?.some(c => c.content))) { - blocks.push(textOf(node)); +/** + * 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; } - node.content?.forEach(walk); + 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)); }; - json.content?.forEach(walk); - const paragraphs = blocks.map(text => - schema.nodes.paragraph.create(null, text ? schema.text(text) : null) - ); - return paragraphs.length - ? schema.topNodeType.create(null, paragraphs) - : emptyDoc(schema); + + content.forEach(visit); + flush(); + return blocks; } -/** Loads JSON into a document of `schema`. Never throws. */ +/** + * Loads JSON into a document of `schema`, repairing what the schema cannot + * hold. Never throws. + */ export function docFromJSON(schema: Schema, json: EditorJSON): PMNode { - if (!json || json.type !== 'doc' || !json.content?.length) { - return emptyDoc(schema); - } - const [clean] = sanitize(schema, json, false); try { - const doc = schema.nodeFromJSON(clean); + 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 fallbackDoc(schema, json); + return emptyDoc(schema); } } diff --git a/packages/raystack/components/editor/core/keymaps.ts b/packages/raystack/components/editor/core/keymaps.ts index 49e2c1fd5..bc80924f0 100644 --- a/packages/raystack/components/editor/core/keymaps.ts +++ b/packages/raystack/components/editor/core/keymaps.ts @@ -14,6 +14,7 @@ import { 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; @@ -60,18 +61,15 @@ const resetBlockAtStart: Command = (state, dispatch) => { return setBlockType(state.schema.nodes.paragraph)(state, dispatch); }; -const insertHardBreak: Command = (state, dispatch) => { - const type = state.schema.nodes.hardBreak; - if (!type) return false; - dispatch?.(state.tr.replaceSelectionWith(type.create()).scrollIntoView()); - return true; -}; - /** * Keys for lists, mentions and block edges. Sits after the shortcut keymap - * and before history and the base keymap. + * and before history and the base keymap. `Mod-y` redoes unless `redo` is + * false. */ -export function buildEditingKeymap(schema: Schema): Plugin { +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 @@ -89,7 +87,7 @@ export function buildEditingKeymap(schema: Schema): Plugin { taskItem ? liftListItem(taskItem) : null ); - return keymap({ + const bindings: Record = { Enter: splitItem, 'Shift-Enter': chainCommands(newlineInCode, insertHardBreak), 'Mod-Enter': exitCode, @@ -105,7 +103,8 @@ export function buildEditingKeymap(schema: Schema): Plugin { ), Delete: deleteAdjacentMention(1), ArrowLeft: moveOverMention(-1), - ArrowRight: moveOverMention(1), - 'Mod-y': redo - }); + 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 index b62f04000..dba485f05 100644 --- a/packages/raystack/components/editor/core/link.ts +++ b/packages/raystack/components/editor/core/link.ts @@ -2,23 +2,54 @@ 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, so they are removed before the check. + * 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 = href.replace(/[\u0000- \u007f]/g, ''); + 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 = input.trim(); + 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.ts b/packages/raystack/components/editor/core/mention.ts index 3a6f3bbba..1ef20de0e 100644 --- a/packages/raystack/components/editor/core/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 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 index 419440273..ab51629dc 100644 --- a/packages/raystack/components/editor/core/schema.ts +++ b/packages/raystack/components/editor/core/schema.ts @@ -4,8 +4,8 @@ import { Schema, type TagParseRule } from 'prosemirror-model'; -import { isSafeHref } from './link'; -import type { MentionAttrs } from './mention'; +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'; @@ -44,74 +44,18 @@ export const EDITOR_FORMATS: readonly EditorFormat[] = [ 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'); } -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: '@' } - }, - 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 { 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 paragraphNodeSpec: NodeSpec = { - content: 'inline*', - group: 'block', - parseDOM: [{ tag: 'p' }], - toDOM: () => ['p', 0] -}; - -export const hardBreakNodeSpec: NodeSpec = { - inline: true, - group: 'inline', - selectable: false, - parseDOM: [{ tag: 'br' }], - toDOM: () => ['br'] -}; - const headingNodeSpec: NodeSpec = { attrs: { level: { default: 1 } }, content: 'inline*', @@ -250,7 +194,9 @@ const linkMarkSpec: MarkSpec = { toDOM: mark => [ 'a', { - href: isSafeHref(mark.attrs.href) ? mark.attrs.href : null, + href: isSafeHref(mark.attrs.href) + ? stripControlCharacters(mark.attrs.href) + : null, rel: 'noopener noreferrer nofollow' }, 0 @@ -364,8 +310,3 @@ export function buildSchema( schemaCache.set(key, schema); return schema; } - -/** Whether `schema` has the node or mark behind a format. */ -export function hasFormat(schema: Schema, format: EditorFormat): boolean { - return format in schema.nodes || format in schema.marks; -} diff --git a/packages/raystack/components/editor/core/serializers.ts b/packages/raystack/components/editor/core/serializers.ts index 333b25761..646e23041 100644 --- a/packages/raystack/components/editor/core/serializers.ts +++ b/packages/raystack/components/editor/core/serializers.ts @@ -1,27 +1,10 @@ -import type { - DOMOutputSpec, - Mark, - Node as PMNode, - Schema -} from 'prosemirror-model'; +import type { DOMOutputSpec, Mark, Node as PMNode } from 'prosemirror-model'; +import { escapeAttribute, escapeText } from './html'; import { docFromJSON, type EditorJSON } from './json'; -import type { EditorMention, MentionAttrs } from './mention'; +import { type EditorMention, type MentionAttrs, mentionText } from './mention'; import { buildSchema } from './schema'; -const VOID_ELEMENTS = new Set(['br', 'hr', 'img', 'input']); - -const HOLE = '\u0000'; - -function escapeText(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>'); -} - -function escapeAttribute(value: string): string { - return escapeText(value).replace(/"/g, '"'); -} +const VOID_ELEMENTS = new Set(['br', 'hr', 'input']); function isAttrs(value: unknown): value is Record { return ( @@ -32,13 +15,22 @@ function isAttrs(value: unknown): value is Record { ); } -/** Writes a `toDOM` spec as a string. `hole` fills the content slot. */ -function renderSpec(spec: DOMOutputSpec, hole: () => string): string { - if (typeof spec === 'string') return escapeText(spec); - if (!Array.isArray(spec)) return ''; +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 [rawTag, ...rest] = spec as readonly [string, ...unknown[]]; - const tag = rawTag.includes(' ') ? rawTag.split(' ')[1] : rawTag; + const [tag, ...rest] = spec as readonly [string, ...unknown[]]; let children = rest; let attrs = ''; if (isAttrs(rest[0])) { @@ -49,22 +41,36 @@ function renderSpec(spec: DOMOutputSpec, hole: () => string): string { } } - if (VOID_ELEMENTS.has(tag)) return `<${tag}${attrs}>`; + let before = `<${tag}${attrs}>`; + if (VOID_ELEMENTS.has(tag)) return { before, after: '', hole: false }; - let inner = ''; + 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]]`. - inner += child === 0 ? hole() : renderSpec(child as DOMOutputSpec, hole); + 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 `<${tag}${attrs}>${inner}`; + return { before, after: `${after}`, hole }; } function markTags(mark: Mark): [string, string] { const toDOM = mark.type.spec.toDOM; if (!toDOM) return ['', '']; - const html = renderSpec(toDOM(mark, true), () => HOLE); - const [open, close = ''] = html.split(HOLE); - return [open, close]; + const { before, after } = renderSpec(toDOM(mark, true)); + return [before, after]; } function serializeChildren(node: PMNode): string { @@ -110,7 +116,8 @@ function serializeNode(node: PMNode): string { if (node.isText) return escapeText(node.text ?? ''); const toDOM = node.type.spec.toDOM; if (!toDOM) return serializeChildren(node); - return renderSpec(toDOM(node), () => 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. */ @@ -145,7 +152,7 @@ export function docToText(doc: PMNode): DocText { } if (node.type.name === 'mention') { const attrs = node.attrs as MentionAttrs; - const label = `${attrs.trigger}${attrs.label}`; + const label = mentionText(attrs); mentions.push({ ...attrs, start: text.length, @@ -164,9 +171,20 @@ export function docToText(doc: PMNode): DocText { return { text, mentions }; } -/** The mentions in a document, in document order. */ -export function docMentions(doc: PMNode): EditorMention[] { - return docToText(doc).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; } /** @@ -197,16 +215,12 @@ export function showsPlaceholder(doc: PMNode): boolean { ); } -let fullSchema: Schema | undefined; - /** Converts editor JSON to HTML. It needs no DOM, so it runs on the server. */ export function editorToHTML(value: EditorJSON): string { - fullSchema ??= buildSchema(); - return docToHTML(docFromJSON(fullSchema, value)); + return docToHTML(docFromJSON(buildSchema(), value)); } /** Converts editor JSON to plain text. Mentions read as `@label`. */ export function editorToText(value: EditorJSON): string { - fullSchema ??= buildSchema(); - return docToText(docFromJSON(fullSchema, value)).text; + return docToText(docFromJSON(buildSchema(), value)).text; } diff --git a/packages/raystack/components/editor/core/suggestion-menu.tsx b/packages/raystack/components/editor/core/suggestion-menu.tsx index d35809200..ac8902da1 100644 --- a/packages/raystack/components/editor/core/suggestion-menu.tsx +++ b/packages/raystack/components/editor/core/suggestion-menu.tsx @@ -10,6 +10,8 @@ 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; @@ -156,7 +158,16 @@ export function SuggestionMenu({ if (!item.disabled) onSelect(item, index); }} > - {item.label} + {item.description ? ( + + {item.label} + + {item.description} + + + ) : ( + item.label + )} ); })} diff --git a/packages/raystack/components/editor/core/task-item-view.ts b/packages/raystack/components/editor/core/task-item-view.ts index 0b2c92c75..29054514a 100644 --- a/packages/raystack/components/editor/core/task-item-view.ts +++ b/packages/raystack/components/editor/core/task-item-view.ts @@ -17,7 +17,8 @@ export class TaskItemView implements NodeView { node: PMNode, private readonly view: EditorView, private readonly getPos: () => number | undefined, - className: { item: string; checkbox: string; content: string } + className: { item: string; checkbox: string; content: string }, + private readonly onDestroy?: () => void ) { this.node = node; @@ -45,6 +46,12 @@ export class TaskItemView implements NodeView { 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) { @@ -82,4 +89,8 @@ export class TaskItemView implements NodeView { 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 index 06d979b6d..c3d28db3a 100644 --- a/packages/raystack/components/editor/core/use-suggestion-menu.ts +++ b/packages/raystack/components/editor/core/use-suggestion-menu.ts @@ -1,6 +1,10 @@ 'use client'; -import { compareItems, rankItem } from '@tanstack/match-sorter-utils'; +import { + type Accessor, + compareItems, + rankItem +} from '@tanstack/match-sorter-utils'; import type { EditorView } from 'prosemirror-view'; import { useCallback, @@ -10,11 +14,12 @@ import { useState, useSyncExternalStore } from 'react'; -import type { EditorMention } from './mention'; +import { type MentionAttrs, mentionKey } from './mention'; import type { EditorMentionItem, EditorMentionRef, - MentionRegistry + MentionRegistry, + MentionsData } from './mention-registry'; import { type SuggestionAnchor, @@ -88,15 +93,13 @@ export function toGroups( export function filterItems( items: Item[], query: string, - accessors: Array<(item: Item) => string | string[]> + accessors: ReadonlyArray> ): Item[] { if (!query) return items; const ranked = items .map(item => ({ item, - ranking: rankItem(item, query, { - accessors: accessors as Array<(item: unknown) => string | string[]> - }) + ranking: rankItem(item, query, { accessors }) })) .filter(entry => entry.ranking.passed); ranked.sort((a, b) => compareItems(a.ranking, b.ranking)); @@ -430,12 +433,16 @@ export function useSuggestionMenu({ */ export function useMentionResolution( registry: MentionRegistry, - mentions: EditorMention[], - refreshLabels: (labels: Map) => void + 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; @@ -443,11 +450,10 @@ export function useMentionResolution( const byTrigger = new Map(); for (const mention of mentions) { - const key = `${mention.trigger}|${mention.type}|${mention.id}`; + 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; - const config = registry.get(mention.trigger); - if (!config?.resolveMentions) continue; + if (!getResolverRef.current(mention.trigger)) continue; requestedRef.current.add(key); const bucket = byTrigger.get(mention.trigger); const ref = { @@ -462,7 +468,7 @@ export function useMentionResolution( if (byTrigger.size === 0) return; for (const [trigger, refs] of byTrigger) { - const resolve = registry.get(trigger)?.resolveMentions; + const resolve = getResolverRef.current(trigger); if (!resolve) continue; resolve(refs) .then((items: EditorMentionItem[]) => { @@ -471,7 +477,7 @@ export function useMentionResolution( const labels = new Map(); for (const item of items) { labels.set( - `${trigger}|${item.type ?? 'mention'}|${item.id}`, + mentionKey(trigger, item.type ?? 'mention', item.id), item.label ); } diff --git a/packages/raystack/components/editor/editor-content.tsx b/packages/raystack/components/editor/editor-content.tsx index 033a0e4da..674afa914 100644 --- a/packages/raystack/components/editor/editor-content.tsx +++ b/packages/raystack/components/editor/editor-content.tsx @@ -11,6 +11,7 @@ import { 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'; @@ -45,6 +46,14 @@ export function EditorContent({ __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; @@ -55,7 +64,8 @@ export function EditorContent({ state: store.state, editable: () => store.isEditable(), dispatchTransaction: store.dispatch, - nodeViews: store.nodeViews + nodeViews: store.nodeViews, + attributes: { class: classesRef.current } } ); store.attachView(view); @@ -68,6 +78,10 @@ export function EditorContent({ }; }, [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()); @@ -84,13 +98,13 @@ export function EditorContent({ {...props} ref={mergedRef} data-slot='editor-content' - className={cx(styles.content, className)} + className={initialClasses} role='textbox' aria-multiline='true' aria-readonly={readOnly || undefined} aria-disabled={disabled || undefined} + // No `aria-expanded`: `role="textbox"` does not allow it. aria-autocomplete={hasMenus ? 'list' : undefined} - aria-expanded={hasMenus ? menu !== undefined : undefined} aria-controls={menu?.listboxId} aria-activedescendant={menu?.activeOptionId} spellCheck={spellCheck} diff --git a/packages/raystack/components/editor/editor-control.tsx b/packages/raystack/components/editor/editor-control.tsx index 2e264be62..86d55224f 100644 --- a/packages/raystack/components/editor/editor-control.tsx +++ b/packages/raystack/components/editor/editor-control.tsx @@ -1,6 +1,12 @@ 'use client'; -import { type ComponentProps, type ReactElement, useMemo } from 'react'; +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'; @@ -27,33 +33,11 @@ export function EditorShortcutKeys({ ); } -export function EditorTooltip({ - label, - shortcut, - enabled = true, - trigger -}: { - label: string; - shortcut?: string | false; - enabled?: boolean; - trigger: ReactElement; -}) { - if (!enabled) return trigger; - return ( - - - - - {label} - - - - - ); -} - export interface EditorControlBaseProps - extends Omit, 'aria-pressed'> { + extends Omit< + ComponentProps, + 'aria-pressed' | 'render' + > { /** Accessible name and tooltip text. */ label?: string; /** @@ -66,8 +50,10 @@ export interface EditorControlBaseProps interface EditorControlProps extends EditorControlBaseProps { label: string; shortcut?: string | false; - /** Sets `aria-pressed` and `data-active`. Leave undefined for a plain button. */ + /** 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. */ @@ -76,6 +62,8 @@ export function EditorControl({ shortcut, tooltip = true, pressed, + render, + className, onMouseDown, children, ...props @@ -84,23 +72,30 @@ export function EditorControl({ { event.preventDefault(); onMouseDown?.(event); }} {...props} - > - {children} - + /> ); + // 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 index edef53ccc..064bff4a5 100644 --- a/packages/raystack/components/editor/editor-defaults.ts +++ b/packages/raystack/components/editor/editor-defaults.ts @@ -18,7 +18,7 @@ import { UnderlineIcon } from '~/icons'; import type { EditorBlock } from './core/commands'; -import type { EditorHeadingLevel, EditorList, EditorMark } from './core/schema'; +import type { EditorHeadingLevel, EditorMark } from './core/schema'; import type { EditorAction } from './core/shortcuts'; interface ControlDefault { @@ -64,12 +64,6 @@ export const BLOCK_DEFAULTS: Record = { horizontalRule: { label: 'Divider', Icon: MinusIcon } }; -export const LIST_TYPES: readonly EditorList[] = [ - 'bulletList', - 'orderedList', - 'taskList' -]; - export const PARAGRAPH_DEFAULT: ControlDefault = { label: 'Text', Icon: TextIcon, diff --git a/packages/raystack/components/editor/editor-floating-context.ts b/packages/raystack/components/editor/editor-floating-context.ts index 73544b828..66d0204f0 100644 --- a/packages/raystack/components/editor/editor-floating-context.ts +++ b/packages/raystack/components/editor/editor-floating-context.ts @@ -6,7 +6,6 @@ export interface FloatingToolbarContextValue { visible: boolean; /** Swaps the buttons for the link field. */ openLink: () => void; - closeLink: () => void; /** Keeps the toolbar open while a menu from it is open. Returns the release. */ hold: () => () => void; } diff --git a/packages/raystack/components/editor/editor-floating-toolbar.tsx b/packages/raystack/components/editor/editor-floating-toolbar.tsx index 649a3c20c..16869dd06 100644 --- a/packages/raystack/components/editor/editor-floating-toolbar.tsx +++ b/packages/raystack/components/editor/editor-floating-toolbar.tsx @@ -97,6 +97,11 @@ export function EditorFloatingToolbar({ ); 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') && @@ -106,6 +111,14 @@ export function EditorFloatingToolbar({ 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( @@ -126,7 +139,6 @@ export function EditorFloatingToolbar({ () => ({ visible, openLink: () => setMode('link'), - closeLink: () => setMode('buttons'), hold }), [visible, hold] @@ -173,10 +185,17 @@ export function EditorFloatingToolbar({ { - if (open || details.reason !== 'escape-key') return; - setDismissedAt(selectionKey); - setMode('buttons'); - store.view?.focus(); + 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'); + } }} > setFocusWithin(true)} + onFocus={event => { + // 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); diff --git a/packages/raystack/components/editor/editor-heading-menu.tsx b/packages/raystack/components/editor/editor-heading-menu.tsx index 8be87cee5..df95cac21 100644 --- a/packages/raystack/components/editor/editor-heading-menu.tsx +++ b/packages/raystack/components/editor/editor-heading-menu.tsx @@ -7,7 +7,9 @@ import type { EditorControlBaseProps } from './editor-control'; import { HEADING_DEFAULTS, PARAGRAPH_DEFAULT } from './editor-defaults'; import { EditorMenuControl, - type EditorMenuOption + EditorMenuItems, + type EditorMenuOption, + sameFlags } from './editor-menu-control'; export interface EditorHeadingMenuProps extends EditorControlBaseProps { @@ -29,22 +31,50 @@ export function EditorHeadingMenu({ activeTextStyle(state.state) ); const editable = useStoreSelector(store, state => state.isEditable()); - // One flag per row, as a string so an unchanged result skips the render. - const can = useStoreSelector(store, state => - [setParagraph, ...levels.map(level => setHeading(level))] - .map(command => (command(state.state) ? '1' : '0')) - .join('') - ); 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] !== '1', + disabled: current !== 'paragraph' && !can[0], run: () => store.run(setParagraph) }, ...levels.map((level, index) => { @@ -54,27 +84,11 @@ export function EditorHeadingMenu({ ...rest, shortcut: action && store.shortcuts[action], active: current === level, - disabled: current !== level && can[index + 1] !== '1', + disabled: current !== level && !can[index + 1], run: () => store.run(setHeading(level)) }; }) ]; - const Icon = - typeof current === 'number' - ? HEADING_DEFAULTS[current].Icon - : PARAGRAPH_DEFAULT.Icon; - - return ( - } - options={options} - disabled={!editable} - {...props} - /> - ); + return ; } - -EditorHeadingMenu.displayName = 'Editor.HeadingMenu'; diff --git a/packages/raystack/components/editor/editor-history-button.tsx b/packages/raystack/components/editor/editor-history-button.tsx index 93e475a2b..8108b7d18 100644 --- a/packages/raystack/components/editor/editor-history-button.tsx +++ b/packages/raystack/components/editor/editor-history-button.tsx @@ -1,7 +1,7 @@ 'use client'; +import { redo, undo } from 'prosemirror-history'; import { RedoIcon, UndoIcon } from '~/icons'; -import { redoCommand, undoCommand } from './core/commands'; import { useEditorStore, useStoreSelector } from './editor-context'; import { EditorControl, type EditorControlBaseProps } from './editor-control'; @@ -19,7 +19,7 @@ export function EditorHistoryButton({ ...props }: EditorHistoryButtonProps) { const store = useEditorStore('Editor.HistoryButton'); - const command = action === 'undo' ? undoCommand : redoCommand; + const command = action === 'undo' ? undo : redo; const enabled = useStoreSelector( store, current => current.isEditable() && command(current.state) diff --git a/packages/raystack/components/editor/editor-link-button.tsx b/packages/raystack/components/editor/editor-link-button.tsx index a653696fc..d0b58e387 100644 --- a/packages/raystack/components/editor/editor-link-button.tsx +++ b/packages/raystack/components/editor/editor-link-button.tsx @@ -3,15 +3,10 @@ import { useEffect, useState } from 'react'; import { LinkIcon } from '~/icons'; import { Popover } from '../popover'; -import { Toolbar } from '../toolbar'; -import { activeLink } from './core/commands'; +import { activeLink, canSetLink } from './core/commands'; import styles from './editor.module.css'; import { useEditorStore, useStoreSelector } from './editor-context'; -import { - EditorControl, - type EditorControlBaseProps, - EditorTooltip -} from './editor-control'; +import { EditorControl, type EditorControlBaseProps } from './editor-control'; import { useFloatingToolbar } from './editor-floating-context'; import { EditorLinkForm } from './editor-link-form'; @@ -36,14 +31,10 @@ export function EditorLinkButton({ store, current => activeLink(current.state) !== null ); - const enabled = useStoreSelector(store, current => { - const type = current.schema.marks.link; - return ( - current.isEditable() && - !!type && - current.state.selection.$from.parent.type.allowsMarkType(type) - ); - }); + const enabled = useStoreSelector( + store, + current => current.isEditable() && canSetLink(current.state) + ); const openLink = floating?.openLink; const visible = floating?.visible ?? false; @@ -64,18 +55,20 @@ export function EditorLinkButton({ if (!store.schema.marks.link) return null; - const shortcut = store.shortcuts.link; + 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); @@ -89,28 +82,14 @@ export function EditorLinkButton({ return ( - - } - > - {content} - - } - /> + } + onClick={onClick} + {...props} + > + {content} + activeList(state.state)); const editable = useStoreSelector(store, state => state.isEditable()); - const can = useStoreSelector(store, state => - types.map(type => (toggleList(type)(state.state) ? '1' : '0')).join('') - ); const available = types.filter(type => type in store.schema.nodes); if (available.length === 0) return null; - const options: EditorMenuOption[] = available.map(type => { - const { action, ...rest } = BLOCK_DEFAULTS[type]; - return { - key: type, - ...rest, - shortcut: action && store.shortcuts[action], - active: current === type, - disabled: can[types.indexOf(type)] !== '1', - run: () => store.run(toggleList(type)) - }; - }); - const Icon = BLOCK_DEFAULTS[current ?? 'bulletList'].Icon; return ( @@ -53,11 +40,36 @@ export function EditorListMenu({ data-slot='editor-list-menu' label={label} trigger={} - options={options} 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-mentions.tsx b/packages/raystack/components/editor/editor-mentions.tsx index ea073d564..c8d1953ea 100644 --- a/packages/raystack/components/editor/editor-mentions.tsx +++ b/packages/raystack/components/editor/editor-mentions.tsx @@ -1,18 +1,14 @@ 'use client'; -import { type ReactNode, useEffect } from 'react'; -import type { EditorMention } from './core/mention'; -import { isTriggerCharacter } from './core/mention'; +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 { - useMentionRegistryVersion, - useMentionResolution -} from './core/use-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'; @@ -46,9 +42,9 @@ export interface EditorMentionsProps { onOpenChange?: (open: boolean) => void; } -const NO_MENTIONS: EditorMention[] = []; +const NO_MENTIONS: MentionAttrs[] = []; -function sameMentions(a: EditorMention[], b: EditorMention[]): boolean { +function sameMentions(a: MentionAttrs[], b: MentionAttrs[]): boolean { if (a.length !== b.length) return false; return a.every( (mention, index) => @@ -80,7 +76,6 @@ function MentionsMenu({ onOpenChange }: EditorMentionsProps & { store: EditorStore }) { const registry = store.mentions; - useMentionRegistryVersion(registry); if (process.env.NODE_ENV !== 'production' && !isTriggerCharacter(trigger)) { console.warn( @@ -91,18 +86,13 @@ function MentionsMenu({ useEffect(() => registry.register(trigger), [registry, trigger]); - // Pushed after every render and compared field by field, so inline props - // stay live without restarting an in-flight search. - useEffect(() => { - registry.setData(trigger, { - items, - onSearch, - resolveMentions, - onOpenChange, - emptyMessage, - loadingRowCount - }); - }); + const resolveRef = useRef(resolveMentions); + resolveRef.current = resolveMentions; + const getResolver = useCallback( + (chipTrigger: string) => + chipTrigger === trigger ? resolveRef.current : undefined, + [trigger] + ); const { menu, listboxId } = useEditorSuggestion({ store, @@ -132,7 +122,12 @@ function MentionsMenu({ : NO_MENTIONS, sameMentions ); - useMentionResolution(registry, mentions, store.refreshMentionLabels); + useMentionResolution( + registry, + mentions, + store.refreshMentionLabels, + getResolver + ); return ( 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; - options: EditorMenuOption[]; + /** The rows. They render only while the menu is open. */ + children: ReactNode; } /** A toolbar button that opens a menu of block types. */ @@ -34,8 +39,8 @@ export function EditorMenuControl({ label, tooltip = true, trigger, - options, disabled, + children, ...props }: EditorMenuControlProps) { const store = useEditorStore('Editor.Toolbar'); @@ -54,49 +59,43 @@ export function EditorMenuControl({ return ( - - } - > - {trigger} - - - } - /> + tooltip={tooltip} + disabled={disabled} + render={} + {...props} + > + {trigger} + + - {options.map(option => ( - } - trailingIcon={ - - - - - } - onClick={option.run} - > - {option.label} - - ))} + {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 index fd9f21c6e..3f44975e3 100644 --- a/packages/raystack/components/editor/editor-root.tsx +++ b/packages/raystack/components/editor/editor-root.tsx @@ -71,7 +71,7 @@ export interface EditorProps< 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`. */ + /** Fires once per doc change. It does not fire for changes made through `value`, or for labels from `resolveMentions`. */ onValueChange?: ( value: EditorJSON, details: WithMarkdown diff --git a/packages/raystack/components/editor/editor-slash-items.tsx b/packages/raystack/components/editor/editor-slash-items.tsx index 343f2fdd0..53fcf2f60 100644 --- a/packages/raystack/components/editor/editor-slash-items.tsx +++ b/packages/raystack/components/editor/editor-slash-items.tsx @@ -1,26 +1,96 @@ -import type { EditorFormat } from './core/schema'; +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 { EditorSlashItem } from './editor-types'; +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; } -const builtins = new WeakMap(); - -function builtin(item: EditorSlashItem, meta: Builtin): EditorSlashItem { - builtins.set(item, meta); - return item; +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; @@ -34,118 +104,88 @@ const DividerIcon = BLOCK_DEFAULTS.horizontalRule.Icon; /** The built-in slash commands. Spread them to add your own. */ export const defaultSlashItems: EditorSlashItem[] = [ - builtin( - { - id: 'paragraph', - label: 'Text', - group: 'Text', - keywords: ['paragraph', 'plain'], - icon: , - run: editor => editor.commands.setParagraph() - }, - { action: 'paragraph' } - ), - builtin( - { - id: 'heading1', - label: 'Heading 1', - group: 'Text', - keywords: ['h1', 'title'], - icon: , - run: editor => editor.commands.setHeading(1) - }, - { format: 'heading', action: 'heading1' } - ), - builtin( - { - id: 'heading2', - label: 'Heading 2', - group: 'Text', - keywords: ['h2', 'subtitle'], - icon: , - run: editor => editor.commands.setHeading(2) - }, - { format: 'heading', action: 'heading2' } - ), - builtin( - { - id: 'heading3', - label: 'Heading 3', - group: 'Text', - keywords: ['h3'], - icon: , - run: editor => editor.commands.setHeading(3) - }, - { format: 'heading', action: 'heading3' } - ), - builtin( - { - id: 'bulletList', - label: 'Bulleted list', - group: 'Lists', - keywords: ['unordered', 'ul', 'bullet'], - icon: , - run: editor => editor.commands.toggleList('bulletList') - }, - { format: 'bulletList', action: 'bulletList' } - ), - builtin( - { - id: 'orderedList', - label: 'Numbered list', - group: 'Lists', - keywords: ['ordered', 'ol'], - icon: , - run: editor => editor.commands.toggleList('orderedList') - }, - { format: 'orderedList', action: 'orderedList' } - ), - builtin( - { - id: 'taskList', - label: 'Checklist', - group: 'Lists', - keywords: ['todo', 'task', 'checkbox'], - icon: , - run: editor => editor.commands.toggleList('taskList') - }, - { format: 'taskList', action: 'taskList' } - ), - builtin( - { - id: 'blockquote', - label: 'Quote', - group: 'Blocks', - keywords: ['blockquote', 'citation'], - icon: , - run: editor => editor.commands.toggleBlock('blockquote') - }, - { format: 'blockquote', action: 'blockquote' } - ), - builtin( - { - id: 'codeBlock', - label: 'Code block', - group: 'Blocks', - keywords: ['code', 'pre', 'snippet'], - icon: , - run: editor => editor.commands.toggleBlock('codeBlock') - }, - { format: 'codeBlock', action: 'codeBlock' } - ), - builtin( - { - id: 'horizontalRule', - label: 'Divider', - group: 'Blocks', - keywords: ['hr', 'rule', 'separator', 'line'], - icon: , - run: editor => editor.commands.insertHorizontalRule() - }, - { format: 'horizontalRule' } - ) + { + 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); + 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 index b72ca4abb..64be8f9bd 100644 --- a/packages/raystack/components/editor/editor-slash-menu.tsx +++ b/packages/raystack/components/editor/editor-slash-menu.tsx @@ -1,5 +1,6 @@ 'use client'; +import { rankings } from '@tanstack/match-sorter-utils'; import { type ReactNode, useMemo } from 'react'; import { SuggestionMenu, @@ -7,8 +8,9 @@ import { } from './core/suggestion-menu'; import { filterItems } from './core/use-suggestion-menu'; import styles from './editor.module.css'; -import { useEditorStore } from './editor-context'; +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'; @@ -33,9 +35,17 @@ export interface EditorSlashMenuProps { onOpenChange?: (open: boolean) => void; } +const NO_FLAGS: boolean[] = []; + const slashAccessors = [ (item: EditorSlashItem) => item.label, - (item: EditorSlashItem) => item.keywords ?? [] + (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( @@ -68,13 +78,34 @@ export function EditorSlashMenu({ [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: available, + items: menuItems, filter: filterSlashItems, onSelect: (item, state) => { store.runAfterDelete(state, () => item.run(store.api)); @@ -92,6 +123,7 @@ export function EditorSlashMenu({ return { id: item.id, label: item.label, + description: item.description, icon: item.icon, disabled: item.disabled, trailing: shortcut ? : null diff --git a/packages/raystack/components/editor/editor-store.ts b/packages/raystack/components/editor/editor-store.ts index 8b28e565a..653dcccda 100644 --- a/packages/raystack/components/editor/editor-store.ts +++ b/packages/raystack/components/editor/editor-store.ts @@ -1,9 +1,14 @@ import { baseKeymap } from 'prosemirror-commands'; import { dropCursor } from 'prosemirror-dropcursor'; import { gapCursor } from 'prosemirror-gapcursor'; -import { closeHistory, history } from 'prosemirror-history'; +import { closeHistory, history, redo, undo } from 'prosemirror-history'; import { keymap } from 'prosemirror-keymap'; -import { type Node as PMNode, type Schema, Slice } from 'prosemirror-model'; +import { + Fragment, + type Node as PMNode, + type Schema, + Slice +} from 'prosemirror-model'; import { type Command, EditorState, @@ -12,31 +17,20 @@ import { Selection, type Transaction } from 'prosemirror-state'; -import { Decoration, DecorationSet, type EditorView } from 'prosemirror-view'; +import type { EditorView } from 'prosemirror-view'; +import { EXTERNAL, placeholderPlugin } from './core/base'; import { activeLink, activeTextStyle, - clearFormatting, - insertHorizontalRule, - insertText, + editorCommands, isBlockActive, isMarkActive, - redoCommand, - setHeading, - setLink, - setParagraph, - toggleBlock, - toggleList, - toggleMarkCommand, - undoCommand, - unsetLink + setLink } from './core/commands'; -import coreStyles from './core/editor-core.module.css'; import { buildInputRules } from './core/input-rules'; import { docFromJSON, type EditorJSON, emptyDoc } from './core/json'; import { buildEditingKeymap } from './core/keymaps'; -import { isSafeHref } from './core/link'; -import type { MentionAttrs } from './core/mention'; +import { type MentionAttrs, mentionKey } from './core/mention'; import { MentionNodeView, type MentionPortal, @@ -68,9 +62,6 @@ import type { MarkdownAdapter } from './editor-types'; -/** Marks transactions that came from `value`, so they are not reported back. */ -export const EXTERNAL = 'apsara-editor-external'; - export interface EditorStoreProps { placeholder?: string; disabled: boolean; @@ -99,12 +90,8 @@ interface Target { const URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i; -function leafText(node: PMNode): string { - if (node.type.name === 'hardBreak') return '\n'; - if (node.type.name !== 'mention') return ''; - const attrs = node.attrs as MentionAttrs; - return `${attrs.trigger}${attrs.label}`; -} +// 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); @@ -118,6 +105,27 @@ function register(targets: Set, target: Target): () => void { }; } +/** + * 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 @@ -145,11 +153,13 @@ export class EditorStore { /** The last value this store emitted or loaded. */ private lastValue: EditorJSON | string | undefined; - private markdownOut: { doc: PMNode; markdown: string } | null = null; + /** 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 = { @@ -177,12 +187,23 @@ export class EditorStore { node: PMNode, view: EditorView, getPos: () => number | undefined - ) => - new TaskItemView(node, view, getPos, { - item: styles['task-item'], - checkbox: styles['task-checkbox'], - content: styles['task-content'] - }) + ) => { + 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: { @@ -245,10 +266,12 @@ export class EditorStore { 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; - this.markdownOut = null; - if (!onValueChange) return; onValueChange(value, this.details(doc, value)); } @@ -266,14 +289,23 @@ export class EditorStore { if (!adapter) return details; return { ...details, - getMarkdown: () => { - const markdown = adapter.fromEditor(value); - this.markdownOut = { doc, markdown }; - return markdown; - } + 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); @@ -298,25 +330,35 @@ export class EditorStore { /** * Applies a controlled `value`. Nothing happens when it is the value the - * store last emitted, or when it loads to the doc the editor already has. + * 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' && - this.markdownOut?.markdown === value && - this.markdownOut.doc === this.state.doc + adapter && + this.markdownOf(this.state.doc, adapter) === value ) { - this.lastValue = value; return; } - this.lastValue = value; const doc = this.parse(value); if (doc.eq(this.state.doc)) return; - const tr = this.replaceDocTransaction(doc); - tr.setMeta(EXTERNAL, true); - tr.setMeta('addToHistory', false); - this.dispatch(tr); + 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 { @@ -338,6 +380,7 @@ export class EditorStore { 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) { @@ -348,21 +391,44 @@ export class EditorStore { // ---- view ---- - private handleMouseUp = () => { + 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; - view.dom.ownerDocument.addEventListener('mouseup', this.handleMouseUp); + 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; - view.dom.ownerDocument.removeEventListener('mouseup', this.handleMouseUp); + 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; @@ -397,12 +463,14 @@ export class EditorStore { this.triggers.get(state.trigger)?.onKeyDown(event, state) ?? false }), keymap(this.shortcutBindings()), - buildEditingKeymap(this.schema), + buildEditingKeymap(this.schema, { + redo: this.shortcuts.redo !== false + }), history(), keymap(baseKeymap), dropCursor({ class: styles['drop-cursor'], color: false }), gapCursor(), - this.placeholderPlugin(), + placeholderPlugin(() => this.props.placeholder, showsPlaceholder), this.eventsPlugin() ]; if (inputRules) plugins.splice(3, 0, inputRules); @@ -411,24 +479,24 @@ export class EditorStore { private shortcutBindings(): Record { const commands: Record = { - bold: toggleMarkCommand('bold'), - italic: toggleMarkCommand('italic'), - underline: toggleMarkCommand('underline'), - strike: toggleMarkCommand('strike'), - code: toggleMarkCommand('code'), + bold: editorCommands.toggleMark('bold'), + italic: editorCommands.toggleMark('italic'), + underline: editorCommands.toggleMark('underline'), + strike: editorCommands.toggleMark('strike'), + code: editorCommands.toggleMark('code'), link: () => this.openLink(), - paragraph: setParagraph, - heading1: setHeading(1), - heading2: setHeading(2), - heading3: setHeading(3), - heading4: setHeading(4), - bulletList: toggleList('bulletList'), - orderedList: toggleList('orderedList'), - taskList: toggleList('taskList'), - blockquote: toggleBlock('blockquote'), - codeBlock: toggleBlock('codeBlock'), - undo: undoCommand, - redo: redoCommand, + 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 = {}; @@ -438,24 +506,6 @@ export class EditorStore { return bindings; } - private placeholderPlugin(): Plugin { - return new Plugin({ - props: { - decorations: state => { - const text = this.props.placeholder; - const first = state.doc.firstChild; - if (!text || !first || !showsPlaceholder(state.doc)) return null; - return DecorationSet.create(state.doc, [ - Decoration.node(0, first.nodeSize, { - class: coreStyles.placeholder, - 'data-placeholder': text - }) - ]); - } - } - }); - } - private eventsPlugin(): Plugin { return new Plugin({ props: { @@ -481,51 +531,48 @@ export class EditorStore { const data = event.clipboardData; if (!data) return false; const text = data.getData('text/plain'); - const hasHtml = data.types.includes('text/html'); + const url = text.trim(); // A URL pasted over a selection links the selection. if ( - text && !view.state.selection.empty && - URL_PATTERN.test(text.trim()) && - isSafeHref(text.trim()) && - setLink(text.trim())(view.state, view.dispatch) + 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 || - hasHtml || !text || - view.state.selection.$from.parent.type.spec.code + 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(Slice.maxOpen(doc.content)) + .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', - leafText - ); + return slice.content.textBetween(0, slice.content.size, '\n\n'); } } }); @@ -613,7 +660,10 @@ export class EditorStore { this.view?.focus(); } - /** Applies fresh labels from `resolveMentions` without touching history. */ + /** + * 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; @@ -621,13 +671,14 @@ export class EditorStore { this.state.doc.descendants((node, pos) => { if (node.type.name !== 'mention') return; const attrs = node.attrs as MentionAttrs; - const fresh = labels.get(`${attrs.trigger}|${attrs.type}|${attrs.id}`); + 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); }; @@ -655,15 +706,16 @@ export class EditorStore { runCommand: (command: Command) => boolean ): EditorCommands { return { - toggleMark: mark => runCommand(toggleMarkCommand(mark)), - setParagraph: () => runCommand(setParagraph), - setHeading: level => runCommand(setHeading(level)), - toggleBlock: block => runCommand(toggleBlock(block)), - toggleList: list => runCommand(toggleList(list)), - setLink: href => runCommand(setLink(href)), - unsetLink: () => runCommand(unsetLink), - insertHorizontalRule: () => runCommand(insertHorizontalRule), - insertText: text => runCommand(insertText(text)), + 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; @@ -672,22 +724,18 @@ export class EditorStore { options?.trigger ?? this.mentions.triggers()[0] ?? '@'; const type = item.type ?? 'mention'; this.mentions.remember(trigger, { ...item, type }); - // Not focused: the chip belongs at the end of the doc. - const end = Selection.atEnd(state.doc).from; - const range = this.view?.hasFocus() - ? undefined - : { from: end, to: end }; - const tr = mentionTransaction( - state, - { id: item.id, label: item.label, type, trigger }, - range - ); + const tr = mentionTransaction(state, { + id: item.id, + label: item.label, + type, + trigger + }); if (tr) dispatch(tr); return true; }), - clearFormatting: () => runCommand(clearFormatting), - undo: () => runCommand(undoCommand), - redo: () => runCommand(redoCommand), + clearFormatting: () => runCommand(editorCommands.clearFormatting()), + undo: () => runCommand(undo), + redo: () => runCommand(redo), setContent: value => runCommand((state, dispatch) => { if (dispatch) dispatch(this.replaceDocTransaction(this.parse(value))); @@ -728,7 +776,7 @@ export class EditorStore { } return null; } - return adapter.fromEditor(store.state.doc.toJSON() as EditorJSON); + return store.markdownOf(store.state.doc, adapter); }, focus: position => store.focus(position) }; diff --git a/packages/raystack/components/editor/editor-types.ts b/packages/raystack/components/editor/editor-types.ts index 5e259adcc..7c0855d8f 100644 --- a/packages/raystack/components/editor/editor-types.ts +++ b/packages/raystack/components/editor/editor-types.ts @@ -1,7 +1,6 @@ import type { EditorState } from 'prosemirror-state'; import type { EditorView } from 'prosemirror-view'; import type { ReactNode } from 'react'; -import type { EditorBlock } from './core/commands'; import type { EditorJSON } from './core/json'; import type { EditorMention } from './core/mention'; import type { EditorMentionItem } from './core/mention-registry'; @@ -85,15 +84,14 @@ export interface MarkdownAdapter { 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. */ + /** 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 type { EditorBlock }; diff --git a/packages/raystack/components/editor/editor.module.css b/packages/raystack/components/editor/editor.module.css index 342767579..deec38a36 100644 --- a/packages/raystack/components/editor/editor.module.css +++ b/packages/raystack/components/editor/editor.module.css @@ -7,18 +7,12 @@ /* ---- 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; min-height: var(--rs-space-8); - outline: none; - white-space: pre-wrap; - overflow-wrap: break-word; - word-break: break-word; color: var(--rs-color-foreground-base-primary); font-family: var(--rs-font-body); font-size: var(--rs-font-size-regular); @@ -45,7 +39,6 @@ .content h4 { font-family: var(--rs-font-title); font-weight: var(--rs-font-weight-medium); - color: var(--rs-color-foreground-base-primary); } .content h1 { @@ -66,11 +59,6 @@ letter-spacing: var(--rs-letter-spacing-large); } -.content h4 { - font-size: var(--rs-font-size-regular); - line-height: var(--rs-line-height-regular); -} - .content > h1:not(:first-child), .content > h2:not(:first-child), .content > h3:not(:first-child) { @@ -83,10 +71,17 @@ } .content li + li, -.content li > * + * { +.content li > * + *, +.task-content > * + * { margin-top: var(--rs-space-2); } +/* 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; +} + .content ul[data-type="taskList"] { padding-left: 0; list-style: none; @@ -113,6 +108,10 @@ cursor: pointer; } +.task-checkbox:disabled { + cursor: default; +} + .task-content { flex: 1; min-width: 0; @@ -168,26 +167,8 @@ border-top-color: var(--rs-color-border-accent-emphasis); } -/* prosemirror-view ships a stylesheet the editor does not load. These rules - from it are load-bearing. */ - -/* ProseMirror appends a zero-size after a block that ends in a - `contentEditable="false"` node, so the browser can draw a caret past it. An - app-level `img { display: block }` reset turns it into a phantom line. */ -.content :global(img.ProseMirror-separator) { - display: inline !important; - border: none !important; - margin: 0 !important; -} - -.content:global(.ProseMirror-hideselection) { - caret-color: transparent; -} - -.content:global(.ProseMirror-hideselection) *::selection { - background: transparent; -} - +/* The gap cursor rules from prosemirror-gapcursor's stylesheet, which the + editor does not load. */ .content :global(.ProseMirror-gapcursor) { display: none; position: absolute; @@ -223,6 +204,15 @@ flex-wrap: wrap; } +/* A pressed toggle is filled, like a pressed Toggle. */ +.control[aria-pressed="true"] { + background-color: var(--rs-color-background-neutral-secondary); +} + +.control[aria-pressed="true"]:hover { + background-color: var(--rs-color-background-neutral-secondary-hover); +} + .floating-popup { min-width: 0; max-width: none; diff --git a/packages/raystack/components/editor/index.tsx b/packages/raystack/components/editor/index.tsx index bfc8c7540..0836a9171 100644 --- a/packages/raystack/components/editor/index.tsx +++ b/packages/raystack/components/editor/index.tsx @@ -1,4 +1,4 @@ -export type { EditorBlock } from './core/commands'; +export { type EditorBlock, editorCommands } from './core/commands'; export type { EditorJSON } from './core/json'; export type { EditorMention } from './core/mention'; export type { diff --git a/packages/raystack/components/editor/markdown/__tests__/bundle.test.ts b/packages/raystack/components/editor/markdown/__tests__/bundle.test.ts deleted file mode 100644 index cdba64c17..000000000 --- a/packages/raystack/components/editor/markdown/__tests__/bundle.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { mkdtempSync, 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 = process.cwd(); -const EDITOR_INDEX = resolve(PACKAGE_ROOT, 'components/editor/index.tsx'); - -/** 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-')); - 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 declares `"sideEffects": false`, so an app's bundler drops - // an Apsara module whose exports it does not use. - treeshake: { moduleSideEffects: (_id, external) => external }, - 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'); -} - -describe('Markdown bundle cost', () => { - it('leaves marked out of a JSON-only app', async () => { - const code = await bundle( - `import { Editor } from ${JSON.stringify(EDITOR_INDEX)};\n` + - 'export const editor = Editor;\n' - ); - expect(code).toContain('Editor.Content'); - expect(code).not.toMatch(/from ['"]marked['"]/); - }, 120_000); - - it('includes marked when the app imports the adapter', async () => { - const code = await bundle( - `import { MarkdownAdapter } from ${JSON.stringify(EDITOR_INDEX)};\n` + - 'export const adapter = MarkdownAdapter.create();\n' - ); - expect(code).toMatch(/from ['"]marked['"]/); - }, 120_000); -}); diff --git a/packages/raystack/components/editor/markdown/__tests__/markdown.test.tsx b/packages/raystack/components/editor/markdown/__tests__/markdown.test.tsx deleted file mode 100644 index 41397ffbe..000000000 --- a/packages/raystack/components/editor/markdown/__tests__/markdown.test.tsx +++ /dev/null @@ -1,325 +0,0 @@ -import { readdirSync, readFileSync, statSync } from 'node:fs'; -import { join, resolve } from 'node:path'; -import { render } from '@testing-library/react'; -import { createRef, useState } from 'react'; -import { describe, expect, it, vi } from 'vitest'; -import { contentOf, paste } from '../../__tests__/test-utils'; -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 '../index'; - -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 -}); - -/** 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; -} - -const EVERYTHING = doc( - { type: 'heading', attrs: { level: 1 }, content: [t('Release notes')] }, - { type: 'heading', attrs: { level: 4 }, content: [t('Small')] }, - 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', [{ type: 'link', attrs: { href: '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')), - { - type: 'bulletList', - content: [{ type: 'listItem', content: [para(t('Nested'))] }] - } - ] - }, - { type: 'listItem', content: [para(t('Two'))] } - ] - }, - { - type: 'orderedList', - attrs: { start: 3 }, - content: [ - { type: 'listItem', content: [para(t('Three'))] }, - { type: 'listItem', content: [para(t('Four'))] } - ] - }, - { - type: 'taskList', - content: [ - { - type: 'taskItem', - attrs: { checked: true }, - content: [para(t('Done'))] - }, - { - type: 'taskItem', - attrs: { checked: false }, - content: [para(t('Todo'))] - } - ] - }, - para(t('Special *chars* _here_ [x] # not a heading')) -); - -describe('MarkdownAdapter', () => { - it('round-trips every node and mark', () => { - const markdown = MarkdownAdapter.fromEditor(EVERYTHING); - expect(normalize(MarkdownAdapter.toEditor(markdown))).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('.')), - { - type: 'taskList', - content: [ - { - type: 'taskItem', - attrs: { checked: true }, - content: [para(t('Docs'))] - } - ] - } - ) - ) - ).toBe('## Title\n\nShip the **fix**.\n\n- [x] Docs'); - }); - - it('moves whitespace outside marks', () => { - expect( - MarkdownAdapter.fromEditor( - doc(para(t('a'), t(' bold ', [{ type: 'bold' }]), t('b'))) - ) - ).toBe('a **bold** b'); - }); - - it('writes mentions as labels with mentions: "label"', () => { - const adapter = MarkdownAdapter.create({ mentions: 'label' }); - expect( - adapter.fromEditor( - doc( - para({ - type: 'mention', - attrs: { id: '1', label: 'Maya', type: 'user', trigger: '@' } - }) - ) - ) - ).toBe('@Maya'); - }); - - it('drops underline with underline: "drop"', () => { - const adapter = MarkdownAdapter.create({ underline: 'drop' }); - expect(adapter.fromEditor(doc(para(t('u', [{ type: 'underline' }]))))).toBe( - 'u' - ); - }); - - it('keeps raw HTML as text and drops unsafe links', () => { - expect( - MarkdownAdapter.toEditor('\n\n[a](javascript:alert(1))') - ).toEqual(doc(para(t('')), para(t('a')))); - }); - - it('does not read an image as a mention', () => { - expect(MarkdownAdapter.toEditor('![alt](https://x/y.png)')).toEqual( - doc(para(t('alt'))) - ); - }); - - it('clamps heading levels and turns tables into text', () => { - expect( - MarkdownAdapter.toEditor('###### Deep\n\n| a | b |\n| - | - |\n| 1 | 2 |') - ).toEqual( - doc( - { type: 'heading', attrs: { level: 4 }, content: [t('Deep')] }, - para(t('a | b')), - para(t('1 | 2')) - ) - ); - }); -}); - -describe('Editor with the markdown prop', () => { - 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, '!'); - const [value, details] = onValueChange.mock.calls[0]; - expect(value.type).toBe('doc'); - expect(details.getMarkdown()).toBe('**bold** text!'); - }); - - it('parses pasted plain-text Markdown', () => { - const actionsRef = createRef(); - const { container } = render( - - - - ); - paste(contentOf(container), '## Heading\n\n- one\n- two'); - expect(actionsRef.current?.getHTML()).toBe( - '

Heading

  • one

  • two

' - ); - }); - - it('leaves pastes as text with paste: false', () => { - const actionsRef = createRef(); - const { container } = render( - - - - ); - paste(contentOf(container), '**x**'); - expect(actionsRef.current?.getHTML()).toBe('

**x**

'); - }); - - it('keeps the doc when a controlled string is the one it produced', () => { - function Controlled() { - const [markdown, setMarkdown] = useState('Hello'); - return ( - setMarkdown(details.getMarkdown())} - > - - - ); - } - const { container } = render(); - const content = contentOf(container); - paste(content, ' world'); - paste(content, '!'); - expect(content).toHaveTextContent('Hello world!'); - }); - - it('replaces the doc for a new controlled string', () => { - const adapter = MarkdownAdapter.create(); - const { container, rerender } = render( - - - - ); - rerender( - - - - ); - expect(contentOf(container).querySelector('h1')).toHaveTextContent('Two'); - }); - - it('returns Markdown from the api', () => { - const actionsRef = createRef(); - render( - - - - ); - expect(actionsRef.current?.getMarkdown()).toBe('_hi_'); - }); - - it('warns and loads plain text for a string without the prop', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const actionsRef = createRef(); - render( - // @ts-expect-error a string value needs the markdown prop - - - - ); - expect(warn).toHaveBeenCalled(); - expect(actionsRef.current?.getText()).toBe('**x**'); - warn.mockRestore(); - }); -}); - -describe('bundle boundary', () => { - it('has no module outside markdown/ importing from it', () => { - const root = resolve(__dirname, '../..'); - const offenders: string[] = []; - const walk = (dir: string) => { - for (const name of readdirSync(dir)) { - const path = join(dir, name); - if (statSync(path).isDirectory()) { - if (name !== 'markdown' && name !== '__tests__') walk(path); - continue; - } - if (!/\.tsx?$/.test(name) || name === 'index.tsx') continue; - const source = readFileSync(path, 'utf8'); - if (/from '\.{1,2}\/(?:\.\.\/)*markdown/.test(source)) { - offenders.push(path.slice(root.length + 1)); - } - } - }; - walk(root); - expect(offenders).toEqual([]); - }); -}); diff --git a/packages/raystack/components/editor/markdown/from-markdown.ts b/packages/raystack/components/editor/markdown/from-markdown.ts index aee295737..b65ce0bed 100644 --- a/packages/raystack/components/editor/markdown/from-markdown.ts +++ b/packages/raystack/components/editor/markdown/from-markdown.ts @@ -18,6 +18,14 @@ interface MentionToken extends Tokens.Generic { /** 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', @@ -28,7 +36,7 @@ const mentionExtension: TokenizerExtension = { tokenizer: source => { if (source[0] === '!') return undefined; const match = readMention(source, 0); - if (!match) return undefined; + if (!match || isLink(match.attrs)) return undefined; const token: MentionToken = { type: 'mention', raw: source.slice(0, match.next), @@ -57,17 +65,25 @@ const ENTITIES: Record = { 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]+|#\d+|[a-z]+);/gi, (match, entity) => { - if (entity[0] === '#') { + 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); - return Number.isNaN(code) ? match : String.fromCodePoint(code); + const invalid = + code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff); + return invalid ? '�' : String.fromCodePoint(code); } - return ENTITIES[entity.toLowerCase()] ?? match; - }); + ); } function withMark(marks: JSONMark[], mark: JSONMark): JSONMark[] { @@ -135,9 +151,10 @@ function inline(tokens: Token[] | undefined, marks: JSONMark[]): EditorJSON[] { ); break; case 'codespan': + // Code is literal, so `<` stays as typed. nodes.push( ...textNodes( - decodeEntities((token as Tokens.Codespan).text), + (token as Tokens.Codespan).text, withMark(current, { type: 'code' }) ) ); @@ -193,17 +210,32 @@ function itemContent(tokens: Token[]): EditorJSON[] { return content; } -function list(token: Tokens.List): EditorJSON { - if (token.items.some(item => item.task)) { +/** `- [ ]` 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: 'taskList', - content: token.items.map(item => ({ - type: 'taskItem', - attrs: { checked: item.checked === true }, - content: itemContent(item.tokens) - })) + 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) diff --git a/packages/raystack/components/editor/markdown/to-markdown.ts b/packages/raystack/components/editor/markdown/to-markdown.ts index 2896dfed9..b90682ecb 100644 --- a/packages/raystack/components/editor/markdown/to-markdown.ts +++ b/packages/raystack/components/editor/markdown/to-markdown.ts @@ -3,7 +3,13 @@ // rewritten to walk editor JSON instead of a ProseMirror document. import type { EditorJSON } from '../core/json'; -import { type MentionAttrs, serializeMention } from '../core/mention'; +import { isSafeHref, stripControlCharacters } from '../core/link'; +import { + type MentionAttrs, + mentionText, + serializeMention +} from '../core/mention'; +import { LIST_TYPES } from '../core/schema'; type JSONMark = NonNullable[number]; @@ -15,6 +21,10 @@ export interface ToMarkdownOptions { /** 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; @@ -24,16 +34,53 @@ function sameMark(a: JSONMark, b: JSONMark): boolean { return a.type === b.type && a.attrs?.href === b.attrs?.href; } -function escapeText(text: string, atLineStart: boolean): string { - let escaped = text - .replace(/[`*\\~[\]_<]/g, '\\$&') - .replace(/&(?=#?\w+;)/g, '\\&'); - if (atLineStart) { - escaped = escaped - .replace(/^(\s*)([-+>]|#{1,6})(?=\s|$)/, '$1\\$2') - .replace(/^(\s*\d+)([.)])(?=\s|$)/, '$1\\$2'); - } - return escaped; +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 { @@ -53,15 +100,23 @@ class InlineWriter { 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) {} + constructor( + private readonly options: ToMarkdownOptions, + private readonly heading: boolean + ) {} - private open(mark: JSONMark): string { + private opener(mark: JSONMark): string { switch (mark.type) { case 'bold': return '**'; case 'italic': - return '_'; + return '*'; case 'strike': return '~~'; case 'underline': @@ -73,30 +128,81 @@ class InlineWriter { } } - private close(mark: JSONMark): string { + private closer(mark: JSONMark): string { switch (mark.type) { case 'bold': return '**'; case 'italic': - return '_'; + return '*'; case 'strike': return '~~'; case 'underline': return this.options.underline === 'html' ? '
' : ''; case 'link': - return `](${escapeHref(String(mark.attrs?.href ?? ''))})`; + 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) this.out += this.close(mark); + if (mark) closers += this.closer(mark); } - this.out += this.trailing; + 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 = ''; } @@ -112,70 +218,114 @@ class InlineWriter { return keep; } - write(node: EditorJSON) { - let marks = [...(node.marks ?? [])].sort((a, b) => rank(a) - rank(b)); - const code = marks.find(mark => mark.type === 'code'); + 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 === 'text') { - const text = node.text ?? ''; + 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. - if (text.trim() === '' && !code) { - marks = marks.slice(0, this.commonPrefix(marks)); - } + marks = marks.slice(0, this.commonPrefix(marks)); } const keep = this.commonPrefix(marks); this.settle(keep); if (node.type === 'text') { - const text = node.text ?? ''; - 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); - // Markdown does not allow whitespace just inside a delimiter, so it - // moves outside the marks. - const opening = marks.length > keep; - if (opening || code) this.out += lead; - for (const mark of marks.slice(keep)) this.out += this.open(mark); - this.active = marks; - let body = escapeText(opening ? core : lead + core, this.atLineStart); - if (code) body = core ? codeSpan(core) : ''; - this.out += body; - if (code || marks.length) this.trailing = trail; - else this.out += trail; - if (text.length) this.atLineStart = false; + this.writeText(node.text ?? '', marks, keep, code); return; } - for (const mark of marks.slice(keep)) this.out += this.open(mark); - this.active = marks; - if (node.type === 'hardBreak') { - this.out += '\\\n'; - this.atLineStart = true; + // 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; - this.out += - this.options.mentions === 'label' - ? escapeText(`${attrs.trigger}${attrs.label}`, this.atLineStart) - : serializeMention(attrs); + 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) { - const writer = new InlineWriter(options); - for (const node of content ?? []) writer.write(node); +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(); } @@ -193,34 +343,61 @@ function textOf(node: EditorJSON): string { return (node.content ?? []).map(child => child.text ?? '').join(''); } -const LISTS = ['bulletList', 'orderedList', 'taskList']; +/** + * 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 { - const children = item.content ?? []; - let body = ''; - children.forEach((child, index) => { - if (index > 0) body += LISTS.includes(child.type) ? '\n' : '\n\n'; - body += block(child, options); - }); - return body; + return sequence(item.content ?? [], options, child => + LISTS.includes(child.type) ? '\n' : '\n\n' + ); } -function list(node: EditorJSON, options: ToMarkdownOptions): string { +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 marker = '- '; - if (node.type === 'orderedList') marker = `${start + index}. `; + let prefix = `${marker} `; + if (node.type === 'orderedList') prefix = `${first + index}${marker} `; if (node.type === 'taskList') { - marker = item.attrs?.checked ? '- [x] ' : '- [ ] '; + prefix = `${marker} [${item.attrs?.checked ? 'x' : ' '}] `; } const body = listItem(item, options); - return indent(body, marker, ' '.repeat(marker.length)); + return indent(body, prefix, ' '.repeat(prefix.length)); }) .join(loose ? '\n\n' : '\n'); } @@ -230,8 +407,11 @@ function block(node: EditorJSON, options: ToMarkdownOptions): string { case 'paragraph': return inline(node.content, options); case 'heading': { - const level = Number(node.attrs?.level ?? 1); - return `${'#'.repeat(level)} ${inline(node.content, options)}`; + 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) @@ -248,10 +428,6 @@ function block(node: EditorJSON, options: ToMarkdownOptions): string { } case 'horizontalRule': return '---'; - case 'bulletList': - case 'orderedList': - case 'taskList': - return list(node, options); default: return node.content ? blocks(node.content, options) : (node.text ?? ''); } @@ -261,7 +437,7 @@ function blocks( content: EditorJSON[] | undefined, options: ToMarkdownOptions ): string { - return (content ?? []).map(node => block(node, options)).join('\n\n'); + return sequence(content ?? [], options, () => '\n\n'); } /** Serializes editor JSON as Markdown. */ diff --git a/packages/raystack/components/editor/core/__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/core/__tests__/markup.test.ts rename to packages/raystack/components/prompt-input/__tests__/prompt-input-markup.test.ts index 9e417c9c1..0caeb7a9d 100644 --- a/packages/raystack/components/editor/core/__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 '../composer/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-editor.tsx b/packages/raystack/components/prompt-input/prompt-input-editor.tsx index 3d6a7c571..42e83894b 100644 --- a/packages/raystack/components/prompt-input/prompt-input-editor.tsx +++ b/packages/raystack/components/prompt-input/prompt-input-editor.tsx @@ -15,13 +15,13 @@ import { import { createPortal } from 'react-dom'; import { deriveDocDetails, - docFromMarkup, - editorStyles, - SuggestionMenu, - type SuggestionState, - useComposerEditor, - useMentionResolution -} from '../editor/core'; + 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, @@ -190,7 +190,12 @@ export function PromptInputEditor({
[] 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; + } }