diff --git a/packages/base/prosemirror-editor.gts b/packages/base/prosemirror-editor.gts new file mode 100644 index 00000000000..0d9fec2d202 --- /dev/null +++ b/packages/base/prosemirror-editor.gts @@ -0,0 +1,1176 @@ +import { task } from 'ember-concurrency'; +import GlimmerComponent from '@glimmer/component'; +import { cached, tracked } from '@glimmer/tracking'; +import { modifier } from 'ember-modifier'; +import { fn } from '@ember/helper'; +import { on } from '@ember/modifier'; +import { scheduleOnce } from '@ember/runloop'; +import { eq } from '@cardstack/boxel-ui/helpers'; + +import { + resolveCardReference, + trimJsonExtension, + maybeRelativeURL, +} from '@cardstack/runtime-common'; +import { type BaseDef, type CardDef, getComponent } from './card-api'; +import { CardContextConsumer } from './field-component'; + +// The ProseMirrorContext type is defined in the host app's lazy-loaded module. +// We only use it as a type here — the actual module is loaded at runtime via +// globalThis.__loadProseMirror. +interface CardNodeViewTarget { + element: HTMLElement; + cardId: string; + format: 'atom' | 'embedded'; + kind: 'inline' | 'block'; +} + +interface CardRenderTarget extends CardNodeViewTarget { + card: CardDef | null; +} + +interface ProseMirrorContext { + schema: any; + EditorState: any; + EditorView: any; + keymap: (bindings: Record) => any; + baseKeymap: Record; + history: () => any; + undo: any; + redo: any; + toggleMark: (markType: any) => any; + setBlockType: (nodeType: any, attrs?: any) => any; + wrapIn: (nodeType: any) => any; + lift: any; + wrapInList: (listType: any) => any; + splitListItem: (itemType: any) => any; + liftListItem: (itemType: any) => any; + sinkListItem: (itemType: any) => any; + parseMarkdown: (text: string) => any; + serializeMarkdown: (doc: any) => string; + createCardNodeViews: ( + onChange: (targets: CardNodeViewTarget[]) => void, + ) => Record; + createSlashCommandPlugin: ( + onStateChange: (state: SlashCommandState | null) => void, + onSelectItem: (index: number) => void, + onNavigate: (direction: 'up' | 'down') => void, + ) => any; + slashCommandPluginKey: any; +} + +interface SlashCommandState { + active: boolean; + query: string; + from: number; +} + +interface SlashMenuItem { + id: string; + label: string; + description: string; +} + +const SLASH_COMMANDS: SlashMenuItem[] = [ + { id: 'card', label: 'Card', description: 'Insert a card reference' }, +]; + +const SAVE_DEBOUNCE_MS = 500; + +function isInline(kind: string): boolean { + return kind === 'inline'; +} + +function resolveUrl(raw: string, baseUrl: string | null | undefined): string { + try { + return trimJsonExtension(resolveCardReference(raw, baseUrl || undefined)); + } catch { + return trimJsonExtension(raw); + } +} + +function makeCardRef(cardUrl: string, baseUrl: string | null | undefined): string { + if (!baseUrl) return cardUrl; + try { + return maybeRelativeURL(new URL(cardUrl), new URL(baseUrl), undefined); + } catch { + return cardUrl; + } +} + +function labelFromUrl(url: string): string { + let cleaned = trimJsonExtension(url); + let parts = cleaned.split('/'); + return parts[parts.length - 1] || cleaned; +} + +interface ProseMirrorEditorSignature { + Args: { + content: string | null; + onUpdate: (markdown: string) => void; + linkedCards?: CardDef[] | null; + cardReferenceBaseUrl?: string | null; + getCards?: ( + parent: object, + getQuery: () => Record | undefined, + ) => { instances: CardDef[]; isLoading: boolean } | undefined; + }; + Element: HTMLDivElement; +} + +export default class ProseMirrorEditor extends GlimmerComponent { + @tracked _pm: ProseMirrorContext | null = null; + @tracked _nodeViewTargets: CardNodeViewTarget[] = []; + + // Non-tracked staging area — updated synchronously by nodeView callbacks, + // then flushed into the tracked property after rendering to avoid + // Glimmer backtracking assertions. + private _pendingTargets: CardNodeViewTarget[] = []; + + // ── Slash command state ────────────────────────────────────────────────── + @tracked _slashState: SlashCommandState | null = null; + @tracked _slashMenuIndex = 0; + @tracked _menuCoords: { left: number; top: number } | null = null; + + // Card search mode (after selecting /card) + @tracked _cardSearchMode = false; + @tracked _cardSearchText = ''; + @tracked _cardSearchIndex = 0; + + // Format picker (after selecting a card from search) + @tracked _formatPickerCardUrl: string | null = null; + @tracked _formatPickerCardTitle: string | null = null; + + get pm(): ProseMirrorContext | null { + if (!this._pm) { + this._loadProseMirrorTask.perform(); + } + return this._pm; + } + + _loadProseMirrorTask = task({ drop: true }, async () => { + let loadProseMirror = (globalThis as any).__loadProseMirror; + if (typeof loadProseMirror !== 'function') { + return; + } + this._pm = await loadProseMirror(); + }); + + private editorView: any = null; + private lastExternalContent: string | null = null; + private saveTimer: ReturnType | null = null; + private _slotUpdatePending = false; + + // ── Slash command logic ──────────────────────────────────────────────── + + get slashMenuActive(): boolean { + return this._slashState?.active === true && !this._cardSearchMode && !this._formatPickerCardUrl; + } + + get filteredSlashCommands(): SlashMenuItem[] { + let query = (this._slashState?.query ?? '').toLowerCase(); + if (!query) return SLASH_COMMANDS; + return SLASH_COMMANDS.filter((cmd) => cmd.id.startsWith(query)); + } + + get slashMenuStyle(): string { + let coords = this._menuCoords; + if (!coords) return 'display: none'; + return `left: ${coords.left}px; top: ${coords.top}px;`; + } + + private _updateMenuCoords() { + let view = this.editorView; + let state = this._slashState; + if (!view || !state) { + this._menuCoords = null; + return; + } + try { + let coords = view.coordsAtPos(state.from); + let editorRect = view.dom.closest('.prosemirror-editor')?.getBoundingClientRect(); + if (editorRect) { + this._menuCoords = { + left: coords.left - editorRect.left, + top: coords.bottom - editorRect.top + 4, + }; + } + } catch { + this._menuCoords = null; + } + } + + private _handleSlashStateChange = (state: SlashCommandState | null) => { + if (!state?.active && this._slashState?.active) { + // Slash menu closing — if we're not in card search mode, clean up + if (!this._cardSearchMode && !this._formatPickerCardUrl) { + this._slashState = null; + this._menuCoords = null; + this._slashMenuIndex = 0; + return; + } + } + this._slashState = state; + this._slashMenuIndex = 0; + if (state?.active) { + this._updateMenuCoords(); + } else if (!this._cardSearchMode && !this._formatPickerCardUrl) { + this._menuCoords = null; + } + }; + + private _handleSlashNavigate = (direction: 'up' | 'down') => { + if (this._cardSearchMode) { + let results = this.cardSearchResults; + let max = results.length; + if (max === 0) return; + if (direction === 'down') { + this._cardSearchIndex = (this._cardSearchIndex + 1) % max; + } else { + this._cardSearchIndex = (this._cardSearchIndex - 1 + max) % max; + } + return; + } + let items = this.filteredSlashCommands; + let max = items.length; + if (max === 0) return; + if (direction === 'down') { + this._slashMenuIndex = (this._slashMenuIndex + 1) % max; + } else { + this._slashMenuIndex = (this._slashMenuIndex - 1 + max) % max; + } + }; + + private _handleSlashSelect = (_index: number) => { + if (this._formatPickerCardUrl) { + // Enter in format picker defaults to block + this._insertCardWithFormat('block'); + return; + } + if (this._cardSearchMode) { + let results = this.cardSearchResults; + let card = results[this._cardSearchIndex]; + if (card) { + this._selectCardResult(card); + } + return; + } + let items = this.filteredSlashCommands; + let item = items[this._slashMenuIndex]; + if (item) { + this._selectSlashCommand(item); + } + }; + + _selectSlashCommand = (cmd: SlashMenuItem) => { + if (cmd.id === 'card') { + // Delete the slash command text from the editor + let view = this.editorView; + let pm = this._pm; + let state = this._slashState; + if (view && pm && state) { + let pluginKey = pm.slashCommandPluginKey; + let tr = view.state.tr + .delete(state.from, view.state.selection.from) + .setMeta(pluginKey, null); + view.dispatch(tr); + } + this._cardSearchMode = true; + this._cardSearchText = ''; + this._cardSearchIndex = 0; + // Focus the search input after render + scheduleOnce('afterRender', this, this._focusSearchInput); + } + }; + + private _focusSearchInput = () => { + let input = document.querySelector('[data-test-card-search-input]') as HTMLInputElement; + input?.focus(); + }; + + // ── Card search ────────────────────────────────────────────────────────── + + private _searchResourceCreated = false; + private _searchResource: { instances: CardDef[]; isLoading: boolean } | null = null; + + get cardSearchResults(): CardDef[] { + if (!this._cardSearchMode) return []; + if (!this._searchResourceCreated) { + this._searchResourceCreated = true; + try { + let getCards = this.args.getCards; + if (typeof getCards === 'function') { + this._searchResource = getCards( + this, + () => { + let text = this._cardSearchText?.trim(); + if (!text) return undefined; + return { + filter: { contains: { name: text } }, + page: { size: 10 }, + }; + }, + ) ?? null; + } + } catch { + // Card search not available + } + } + return this._searchResource?.instances ?? []; + } + + get isSearchLoading(): boolean { + return this._searchResource?.isLoading ?? false; + } + + _handleCardSearchInput = (event: Event) => { + this._cardSearchText = (event.target as HTMLInputElement).value; + this._cardSearchIndex = 0; + }; + + _handleCardSearchKeydown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + this._dismissCardSearch(); + return; + } + if (event.key === 'ArrowDown') { + event.preventDefault(); + this._handleSlashNavigate('down'); + return; + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + this._handleSlashNavigate('up'); + return; + } + if (event.key === 'Enter') { + event.preventDefault(); + // Check if the input looks like a URL + let text = this._cardSearchText.trim(); + if (text && (text.startsWith('http://') || text.startsWith('https://') || text.startsWith('./'))) { + this._formatPickerCardUrl = text; + this._formatPickerCardTitle = labelFromUrl(text); + this._cardSearchMode = false; + return; + } + // Otherwise select the highlighted search result + let results = this.cardSearchResults; + let card = results[this._cardSearchIndex]; + if (card) { + this._selectCardResult(card); + } + return; + } + }; + + _selectCardResult = (card: CardDef) => { + if (!card.id) return; + this._formatPickerCardUrl = card.id; + this._formatPickerCardTitle = card.title ?? labelFromUrl(card.id); + this._cardSearchMode = false; + }; + + _dismissCardSearch = () => { + this._cardSearchMode = false; + this._cardSearchText = ''; + this._cardSearchIndex = 0; + this._slashState = null; + this._menuCoords = null; + this.editorView?.focus(); + }; + + // ── Card insertion ─────────────────────────────────────────────────────── + + _insertCardWithFormat = (format: string) => { + let cardUrl = this._formatPickerCardUrl; + if (!cardUrl) return; + + let view = this.editorView; + let pm = this._pm; + if (!view || !pm) return; + + let baseUrl = this.args.cardReferenceBaseUrl; + let ref = makeCardRef(cardUrl, baseUrl); + + if (format === 'inline') { + let node = pm.schema.nodes.boxel_card_atom.create({ + cardId: ref, + label: labelFromUrl(ref), + }); + let { from } = view.state.selection; + let tr = view.state.tr.insert(from, node); + view.dispatch(tr); + } else { + let node = pm.schema.nodes.boxel_card_block.create({ + cardId: ref, + }); + let { from } = view.state.selection; + let $pos = view.state.doc.resolve(from); + // Insert after the current block + let insertPos = $pos.after($pos.depth); + let tr = view.state.tr.insert(insertPos, node); + view.dispatch(tr); + } + + // Clean up all popup state + this._formatPickerCardUrl = null; + this._formatPickerCardTitle = null; + this._cardSearchMode = false; + this._cardSearchText = ''; + this._slashState = null; + this._menuCoords = null; + + view.focus(); + + // Trigger save immediately + let onUpdate = this.args.onUpdate; + if (onUpdate && pm) { + if (this.saveTimer) { + clearTimeout(this.saveTimer); + this.saveTimer = null; + } + let markdown = pm.serializeMarkdown(view.state.doc); + onUpdate(markdown); + } + }; + + _dismissFormatPicker = () => { + this._formatPickerCardUrl = null; + this._formatPickerCardTitle = null; + this._slashState = null; + this._menuCoords = null; + this.editorView?.focus(); + }; + + // ── Card slot resolution ─────────────────────────────────────────────── + + @cached + get cardRenderTargets(): CardRenderTarget[] { + let targets = this._nodeViewTargets; + let linkedCards = this.args.linkedCards; + let baseUrl = this.args.cardReferenceBaseUrl; + + let cardsByUrl = new Map(); + if (linkedCards?.length) { + for (let card of linkedCards) { + if (card?.id) { + cardsByUrl.set(card.id, card); + } + } + } + + return targets.map((target) => { + let resolved = resolveUrl(target.cardId, baseUrl); + return { + ...target, + card: cardsByUrl.get(resolved) ?? null, + }; + }); + } + + private _handleTargetChange = (targets: CardNodeViewTarget[]) => { + this._pendingTargets = targets; + if (!this._slotUpdatePending) { + this._slotUpdatePending = true; + scheduleOnce('afterRender', this, this._applyTargets); + } + }; + + _applyTargets = () => { + this._slotUpdatePending = false; + this._nodeViewTargets = this._pendingTargets; + }; + + getCardComponent = (card: BaseDef) => getComponent(card); + + // ── Editor lifecycle ─────────────────────────────────────────────────── + + mountEditor = modifier( + (element: HTMLElement, _positional: unknown[]) => { + let pm = this._pm; + if (!pm) { + return; + } + + let content = this.args.content; + let onUpdate = this.args.onUpdate; + + // If editor already exists and content hasn't changed externally, keep it + if ( + this.editorView && + element.contains(this.editorView.dom) && + content === this.lastExternalContent + ) { + return; + } + + // External content changed — destroy old editor + if (this.editorView && content !== this.lastExternalContent) { + this.editorView.destroy(); + this.editorView = null; + } + + this.lastExternalContent = content; + + // Clear element before creating editor + element.innerHTML = ''; + + let doc = pm.parseMarkdown(content || ''); + + let { + schema, + keymap: keymapPlugin, + baseKeymap, + history, + undo, + redo, + toggleMark, + splitListItem, + liftListItem, + sinkListItem, + } = pm; + + let formatKeymap = { + 'Mod-b': toggleMark(schema.marks.strong), + 'Mod-i': toggleMark(schema.marks.em), + 'Mod-`': toggleMark(schema.marks.code), + 'Mod-z': undo, + 'Shift-Mod-z': redo, + }; + + let listKeymap = { + Enter: splitListItem(schema.nodes.list_item), + Tab: sinkListItem(schema.nodes.list_item), + 'Shift-Tab': liftListItem(schema.nodes.list_item), + }; + + let slashPlugin = pm.createSlashCommandPlugin( + this._handleSlashStateChange, + this._handleSlashSelect, + this._handleSlashNavigate, + ); + + let state = pm.EditorState.create({ + doc, + plugins: [ + slashPlugin, + keymapPlugin(formatKeymap), + keymapPlugin(listKeymap), + keymapPlugin(baseKeymap), + history(), + ], + }); + + let nodeViews = pm.createCardNodeViews(this._handleTargetChange); + + let view = new pm.EditorView(element, { + state, + nodeViews, + dispatchTransaction: (tr: any) => { + let newState = view.state.apply(tr); + view.updateState(newState); + + if (tr.docChanged && onUpdate) { + // Debounced save + if (this.saveTimer) { + clearTimeout(this.saveTimer); + } + this.saveTimer = setTimeout(() => { + this.saveTimer = null; + let markdown = pm!.serializeMarkdown(view.state.doc); + onUpdate(markdown); + }, SAVE_DEBOUNCE_MS); + } + }, + }); + + this.editorView = view; + + return () => { + if (this.saveTimer) { + clearTimeout(this.saveTimer); + this.saveTimer = null; + } + if (this.editorView) { + this.editorView.destroy(); + this.editorView = null; + } + }; + }, + ); + + // ── Block-level commands (exposed for future toolbar use) ── + + setHeading = (level: number) => { + let pm = this._pm; + let view = this.editorView; + if (!pm || !view) return; + pm.setBlockType(pm.schema.nodes.heading, { level })( + view.state, + view.dispatch, + ); + view.focus(); + }; + + setParagraph = () => { + let pm = this._pm; + let view = this.editorView; + if (!pm || !view) return; + pm.setBlockType(pm.schema.nodes.paragraph)(view.state, view.dispatch); + view.focus(); + }; + + toggleBulletList = () => { + let pm = this._pm; + let view = this.editorView; + if (!pm || !view) return; + pm.wrapInList(pm.schema.nodes.bullet_list)(view.state, view.dispatch); + view.focus(); + }; + + toggleOrderedList = () => { + let pm = this._pm; + let view = this.editorView; + if (!pm || !view) return; + pm.wrapInList(pm.schema.nodes.ordered_list)(view.state, view.dispatch); + view.focus(); + }; + + toggleBlockquote = () => { + let pm = this._pm; + let view = this.editorView; + if (!pm || !view) return; + pm.wrapIn(pm.schema.nodes.blockquote)(view.state, view.dispatch); + view.focus(); + }; + + insertHorizontalRule = () => { + let pm = this._pm; + let view = this.editorView; + if (!pm || !view) return; + let { tr } = view.state; + view.dispatch( + tr.replaceSelectionWith(pm.schema.nodes.horizontal_rule.create()), + ); + view.focus(); + }; + + liftBlock = () => { + let pm = this._pm; + let view = this.editorView; + if (!pm || !view) return; + pm.lift(view.state, view.dispatch); + view.focus(); + }; + + +} diff --git a/packages/base/rich-markdown.gts b/packages/base/rich-markdown.gts new file mode 100644 index 00000000000..385652decf9 --- /dev/null +++ b/packages/base/rich-markdown.gts @@ -0,0 +1,118 @@ +import { + extractCardReferenceUrls, + relativeTo, +} from '@cardstack/runtime-common'; + +import { + CardDef, + Component, + FieldDef, + MarkdownField, + StringField, + contains, + containsMany, + field, + linksToMany, +} from './card-api'; +import MarkdownTemplate from './default-templates/markdown'; +import ProseMirrorEditor from './prosemirror-editor'; +import { CardContextConsumer } from './field-component'; + +/** + * A composite FieldDef that stores markdown content and exposes structured + * `linkedCards` relationships for embedded card references. + * + * Unlike `MarkdownField` (which extends StringField), this field is a + * composite FieldDef with sub-fields for content, computed reference URLs, + * and query-based linked cards. + * + * Usage: + * ``` + * import RichMarkdownField from 'https://cardstack.com/base/rich-markdown'; + * + * class MyCard extends CardDef { + * @field body = contains(RichMarkdownField); + * } + * ``` + */ +export class RichMarkdownField extends FieldDef { + static displayName = 'Rich Markdown'; + + /** The raw markdown text. Uses MarkdownField for textarea edit UI. */ + @field content = contains(MarkdownField); + + /** Resolved absolute URLs of `:card[URL]` and `::card[URL]` references. */ + @field cardReferenceUrls = containsMany(StringField, { + computeVia: function (this: RichMarkdownField) { + if (!this.content) { + return []; + } + let baseUrl = this[relativeTo]?.href ?? ''; + return extractCardReferenceUrls(this.content, baseUrl); + }, + }); + + /** Cards referenced in the markdown, loaded via query. */ + @field linkedCards = linksToMany(CardDef, { + query: { + filter: { + in: { id: '$this.cardReferenceUrls' }, + }, + }, + }); + + static embedded = class Embedded extends Component { + get content() { + return this.args.model?.content ?? null; + } + get baseUrl(): string | null { + return this.args.model?.[relativeTo]?.href ?? null; + } + + }; + + static atom = class Atom extends Component { + get content() { + return this.args.model?.content ?? null; + } + + }; + + static edit = class Edit extends Component { + updateContent = (markdown: string) => { + this.args.model.content = markdown; + }; + get baseUrl(): string | null { + return this.args.model?.[relativeTo]?.href ?? null; + } + get linkedCards(): CardDef[] | null { + try { + return this.args.model?.linkedCards ?? null; + } catch { + // linksToMany query may fail in environments without a full card store + return null; + } + } + + }; +} + +export default RichMarkdownField; diff --git a/packages/experiments-realm/RichMarkdownPlayground/playground.json b/packages/experiments-realm/RichMarkdownPlayground/playground.json new file mode 100644 index 00000000000..6a9d1d2e6da --- /dev/null +++ b/packages/experiments-realm/RichMarkdownPlayground/playground.json @@ -0,0 +1,30 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "RichMarkdownPlayground", + "module": "../rich-markdown-playground" + } + }, + "type": "card", + "attributes": { + "cardInfo": { + "notes": null, + "name": "Rich Markdown Playground", + "summary": "Exercise the RichMarkdownField with its ProseMirror WYSIWYG editor.", + "cardThumbnailURL": null + }, + "title": "Rich Markdown Playground", + "body": { + "content": "# Welcome to the Rich Markdown Playground\n\nThis card uses `RichMarkdownField` which provides a **ProseMirror WYSIWYG editor** in edit mode.\n\n## Features to Try\n\n### Inline Formatting\n\nYou can write **bold text**, *italic text*, and `inline code`.\n\n### Lists\n\n- Bullet list item one\n- Bullet list item two\n- Bullet list item three\n\n1. Ordered list first\n2. Ordered list second\n3. Ordered list third\n\n### Code Block\n\n```typescript\nimport { RichMarkdownField } from 'https://cardstack.com/base/rich-markdown';\n\nclass MyCard extends CardDef {\n @field body = contains(RichMarkdownField);\n}\n```\n\n### Blockquote\n\n> The ProseMirror editor supports keyboard shortcuts like **Cmd+B** for bold, **Cmd+I** for italic, and **Cmd+Z** for undo.\n\n---\n\n### Card References\n\nInline card atom: :card[../Author/jane-doe]\n\nBlock card:\n\n::card[../Author/jane-doe]\n\nSwitch to **Edit** mode to try the WYSIWYG editor!" + } + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} diff --git a/packages/experiments-realm/rich-markdown-playground.gts b/packages/experiments-realm/rich-markdown-playground.gts new file mode 100644 index 00000000000..cf8d86d3983 --- /dev/null +++ b/packages/experiments-realm/rich-markdown-playground.gts @@ -0,0 +1,89 @@ +import { + contains, + field, + CardDef, + Component, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import { RichMarkdownField } from 'https://cardstack.com/base/rich-markdown'; + +export class RichMarkdownPlayground extends CardDef { + static displayName = 'Rich Markdown Playground'; + + @field title = contains(StringField); + @field body = contains(RichMarkdownField); + + static isolated = class Isolated extends Component { + + }; +} diff --git a/packages/host/app/lib/prosemirror-context.ts b/packages/host/app/lib/prosemirror-context.ts new file mode 100644 index 00000000000..43d3cdf13e7 --- /dev/null +++ b/packages/host/app/lib/prosemirror-context.ts @@ -0,0 +1,937 @@ +/** + * ProseMirror context module — lazy-loaded via globalThis.__loadProseMirror. + * + * This module is the single dynamic-import entry point for ProseMirror. + * Webpack will code-split it (and all its transitive deps) into a separate + * chunk automatically. The base package's ProseMirrorEditor component + * consumes the exported context object. + */ + +import 'prosemirror-view/style/prosemirror.css'; + +import { Schema } from 'prosemirror-model'; +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import { EditorState, Plugin, PluginKey } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; +import { keymap } from 'prosemirror-keymap'; +import { baseKeymap, toggleMark, setBlockType, lift } from 'prosemirror-commands'; +import { + wrapInList, + splitListItem, + liftListItem, + sinkListItem, +} from 'prosemirror-schema-list'; +import { history, undo, redo } from 'prosemirror-history'; +import { wrapIn } from 'prosemirror-commands'; + +// ── Schema ────────────────────────────────────────────────────────────────── + +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + + paragraph: { + content: 'inline*', + group: 'block', + parseDOM: [{ tag: 'p' }], + toDOM() { + return ['p', 0]; + }, + }, + + heading: { + attrs: { level: { default: 1 } }, + content: 'inline*', + group: 'block', + defining: true, + parseDOM: [1, 2, 3, 4, 5, 6].map((level) => ({ + tag: `h${level}`, + attrs: { level }, + })), + toDOM(node) { + return [`h${node.attrs.level}`, 0]; + }, + }, + + blockquote: { + content: 'block+', + group: 'block', + defining: true, + parseDOM: [{ tag: 'blockquote' }], + toDOM() { + return ['blockquote', 0]; + }, + }, + + code_block: { + attrs: { info: { default: '' } }, + content: 'text*', + marks: '', + group: 'block', + code: true, + defining: true, + parseDOM: [ + { + tag: 'pre', + preserveWhitespace: 'full' as const, + getAttrs(dom: HTMLElement) { + return { info: dom.getAttribute('data-fence-info') || '' }; + }, + }, + ], + toDOM(node) { + return node.attrs.info + ? ['pre', { 'data-fence-info': node.attrs.info }, ['code', 0]] + : ['pre', ['code', 0]]; + }, + }, + + bullet_list: { + content: 'list_item+', + group: 'block', + parseDOM: [{ tag: 'ul' }], + toDOM() { + return ['ul', 0]; + }, + }, + + ordered_list: { + content: 'list_item+', + group: 'block', + attrs: { order: { default: 1 } }, + parseDOM: [ + { + tag: 'ol', + getAttrs(dom: HTMLElement) { + return { order: dom.getAttribute('start') || 1 }; + }, + }, + ], + toDOM(node) { + return node.attrs.order === 1 + ? ['ol', 0] + : ['ol', { start: node.attrs.order }, 0]; + }, + }, + + list_item: { + content: 'paragraph block*', + defining: true, + parseDOM: [{ tag: 'li' }], + toDOM() { + return ['li', 0]; + }, + }, + + horizontal_rule: { + group: 'block', + parseDOM: [{ tag: 'hr' }], + toDOM() { + return ['hr']; + }, + }, + + hard_break: { + inline: true, + group: 'inline', + selectable: false, + parseDOM: [{ tag: 'br' }], + toDOM() { + return ['br']; + }, + }, + + text: { group: 'inline' }, + + // ── Custom card placeholder nodes ── + + boxel_card_atom: { + attrs: { + cardId: {}, + label: { default: '' }, + }, + inline: true, + group: 'inline', + atom: true, + draggable: true, + parseDOM: [ + { + tag: 'span[data-boxel-card-atom]', + getAttrs(dom: HTMLElement) { + return { + cardId: dom.getAttribute('data-card-id'), + label: dom.getAttribute('data-label') || dom.textContent, + }; + }, + }, + ], + toDOM(node) { + return [ + 'span', + { + 'data-boxel-card-atom': '', + 'data-card-id': node.attrs.cardId, + 'data-label': node.attrs.label, + class: 'boxel-card-atom', + }, + ['span', { class: 'atom-label' }, node.attrs.label || node.attrs.cardId], + ]; + }, + }, + + boxel_card_block: { + attrs: { + cardId: {}, + format: { default: 'embedded' }, + size: { default: 'full' }, + }, + group: 'block', + atom: true, + draggable: true, + parseDOM: [ + { + tag: 'div[data-boxel-card-block]', + getAttrs(dom: HTMLElement) { + return { + cardId: dom.getAttribute('data-card-id'), + format: dom.getAttribute('data-format') || 'embedded', + size: dom.getAttribute('data-size') || 'full', + }; + }, + }, + ], + toDOM(node) { + return [ + 'div', + { + 'data-boxel-card-block': '', + 'data-card-id': node.attrs.cardId, + 'data-format': node.attrs.format, + 'data-size': node.attrs.size, + class: `boxel-card-block format-${node.attrs.format}`, + }, + ['div', { class: 'card-block-id' }, node.attrs.cardId], + ]; + }, + }, + }, + + marks: { + strong: { + parseDOM: [ + { tag: 'strong' }, + { + tag: 'b', + getAttrs(node: HTMLElement) { + return node.style.fontWeight !== 'normal' && null; + }, + }, + { + style: 'font-weight=400', + clearMark(m: any) { + return m.type.name === 'strong'; + }, + }, + { + style: 'font-weight', + getAttrs(value: string) { + return /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null; + }, + }, + ], + toDOM() { + return ['strong', 0]; + }, + }, + + em: { + parseDOM: [{ tag: 'i' }, { tag: 'em' }, { style: 'font-style=italic' }], + toDOM() { + return ['em', 0]; + }, + }, + + code: { + parseDOM: [{ tag: 'code' }], + toDOM() { + return ['code', 0]; + }, + }, + + link: { + attrs: { href: {}, title: { default: null } }, + inclusive: false, + parseDOM: [ + { + tag: 'a[href]', + getAttrs(dom: HTMLElement) { + return { + href: dom.getAttribute('href'), + title: dom.getAttribute('title'), + }; + }, + }, + ], + toDOM(node) { + return [ + 'a', + { href: node.attrs.href, title: node.attrs.title }, + 0, + ]; + }, + }, + }, +}); + +// ── Markdown → ProseMirror ───────────────────────────────────────────────── + +function parseInlineContent(text: string): ProseMirrorNode[] { + let nodes: ProseMirrorNode[] = []; + let remaining = text; + + while (remaining.length > 0) { + // Link: [text](url) or [text](url "title") + let linkMatch = remaining.match( + /^\[([^\]]+)\]\((\S+?)(?:\s+"([^"]*)")?\)/, + ); + if (linkMatch) { + let href = linkMatch[2]; + let title = linkMatch[3] || null; + nodes.push( + schema.text(linkMatch[1], [ + schema.marks.link.create({ href, title }), + ]), + ); + remaining = remaining.slice(linkMatch[0].length); + continue; + } + + // Bold+italic: ***text*** + let boldItalicMatch = remaining.match(/^\*\*\*([^*]+)\*\*\*/); + if (boldItalicMatch) { + nodes.push( + schema.text(boldItalicMatch[1], [ + schema.marks.strong.create(), + schema.marks.em.create(), + ]), + ); + remaining = remaining.slice(boldItalicMatch[0].length); + continue; + } + + // Bold: **text** + let boldMatch = remaining.match(/^\*\*([^*]+)\*\*/); + if (boldMatch) { + nodes.push( + schema.text(boldMatch[1], [schema.marks.strong.create()]), + ); + remaining = remaining.slice(boldMatch[0].length); + continue; + } + + // Italic: *text* + let italicMatch = remaining.match(/^\*([^*]+)\*/); + if (italicMatch) { + nodes.push( + schema.text(italicMatch[1], [schema.marks.em.create()]), + ); + remaining = remaining.slice(italicMatch[0].length); + continue; + } + + // Inline code: `text` + let codeMatch = remaining.match(/^`([^`]+)`/); + if (codeMatch) { + nodes.push( + schema.text(codeMatch[1], [schema.marks.code.create()]), + ); + remaining = remaining.slice(codeMatch[0].length); + continue; + } + + // Block card reference in inline context (::card[URL]) — treat as plain text + let blockCardInlineMatch = remaining.match(/^::card\[([^\]]+)\]/); + if (blockCardInlineMatch) { + nodes.push(schema.text(blockCardInlineMatch[0])); + remaining = remaining.slice(blockCardInlineMatch[0].length); + continue; + } + + // Card atom: :card[URL] + let cardAtomMatch = remaining.match(/^:card\[([^\]]+)\]/); + if (cardAtomMatch) { + let cardId = cardAtomMatch[1].trim(); + let label = cardId.split('/').filter(Boolean).pop() || cardId; + nodes.push(schema.nodes.boxel_card_atom.create({ cardId, label })); + remaining = remaining.slice(cardAtomMatch[0].length); + continue; + } + + // Plain text until next special character + let plainMatch = remaining.match(/^[^*`\[:\n]+/); + if (plainMatch) { + nodes.push(schema.text(plainMatch[0])); + remaining = remaining.slice(plainMatch[0].length); + continue; + } + + // Single special char that didn't match a pattern + nodes.push(schema.text(remaining[0])); + remaining = remaining.slice(1); + } + + return nodes; +} + +function parseMarkdown(text: string): ProseMirrorNode { + if (!text || text.trim() === '') { + return schema.node('doc', null, [schema.node('paragraph')]); + } + + let blocks: ProseMirrorNode[] = []; + let lines = text.split('\n'); + let i = 0; + + while (i < lines.length) { + let line = lines[i]; + + // Empty line — skip + if (line.trim() === '') { + i++; + continue; + } + + // Horizontal rule + if (/^(---|\*\*\*|___)$/.test(line.trim())) { + blocks.push(schema.node('horizontal_rule')); + i++; + continue; + } + + // Code block + if (line.trim().startsWith('```')) { + let info = line.trim().slice(3).trim(); + let codeLines: string[] = []; + i++; + while (i < lines.length && !lines[i].trim().startsWith('```')) { + codeLines.push(lines[i]); + i++; + } + i++; // skip closing ``` + let codeText = codeLines.join('\n'); + blocks.push( + schema.node( + 'code_block', + { info }, + codeText ? [schema.text(codeText)] : [], + ), + ); + continue; + } + + // Heading + let headingMatch = line.match(/^(#{1,6})\s+(.*)$/); + if (headingMatch) { + let level = headingMatch[1].length; + let content = parseInlineContent(headingMatch[2]); + blocks.push( + schema.node('heading', { level }, content.length ? content : undefined), + ); + i++; + continue; + } + + // Block card: ::card[URL] or ::card[URL | specifier] + let blockCardMatch = line.trim().match(/^::card\[([^\]]+)\]/); + if (blockCardMatch) { + let raw = blockCardMatch[1]; + let pipeIdx = raw.indexOf('|'); + let cardId = pipeIdx >= 0 ? raw.substring(0, pipeIdx).trim() : raw.trim(); + blocks.push(schema.nodes.boxel_card_block.create({ cardId })); + i++; + continue; + } + + // Blockquote + if (line.startsWith('> ') || line === '>') { + let quoteLines: string[] = []; + while ( + i < lines.length && + (lines[i].startsWith('> ') || lines[i] === '>') + ) { + quoteLines.push(lines[i] === '>' ? '' : lines[i].slice(2)); + i++; + } + // Split into paragraphs on empty lines within the blockquote + let paragraphs: ProseMirrorNode[] = []; + let currentLines: string[] = []; + for (let ql of quoteLines) { + if (ql === '') { + if (currentLines.length > 0) { + let paraContent = parseInlineContent(currentLines.join(' ')); + paragraphs.push( + schema.node( + 'paragraph', + null, + paraContent.length ? paraContent : undefined, + ), + ); + currentLines = []; + } + } else { + currentLines.push(ql); + } + } + if (currentLines.length > 0) { + let paraContent = parseInlineContent(currentLines.join(' ')); + paragraphs.push( + schema.node( + 'paragraph', + null, + paraContent.length ? paraContent : undefined, + ), + ); + } + blocks.push( + schema.node( + 'blockquote', + null, + paragraphs.length > 0 ? paragraphs : [schema.node('paragraph')], + ), + ); + continue; + } + + // Bullet list + if (/^[-*+]\s/.test(line)) { + let items: ProseMirrorNode[] = []; + while (i < lines.length && /^[-*+]\s/.test(lines[i])) { + let itemText = lines[i].replace(/^[-*+]\s/, ''); + let itemContent = parseInlineContent(itemText); + items.push( + schema.node('list_item', null, [ + schema.node( + 'paragraph', + null, + itemContent.length ? itemContent : undefined, + ), + ]), + ); + i++; + } + blocks.push(schema.node('bullet_list', null, items)); + continue; + } + + // Ordered list + if (/^\d+\.\s/.test(line)) { + let items: ProseMirrorNode[] = []; + let startOrder = parseInt(line.match(/^(\d+)/)![1], 10); + while (i < lines.length && /^\d+\.\s/.test(lines[i])) { + let itemText = lines[i].replace(/^\d+\.\s/, ''); + let itemContent = parseInlineContent(itemText); + items.push( + schema.node('list_item', null, [ + schema.node( + 'paragraph', + null, + itemContent.length ? itemContent : undefined, + ), + ]), + ); + i++; + } + blocks.push(schema.node('ordered_list', { order: startOrder }, items)); + continue; + } + + // Regular paragraph + let content = parseInlineContent(line); + blocks.push( + schema.node('paragraph', null, content.length ? content : undefined), + ); + i++; + } + + return schema.node( + 'doc', + null, + blocks.length > 0 ? blocks : [schema.node('paragraph')], + ); +} + +// ── ProseMirror → Markdown ───────────────────────────────────────────────── + +function serializeInlineContent(node: ProseMirrorNode): string { + let result = ''; + node.forEach((child) => { + if (child.isText) { + let text = child.text || ''; + let marks = child.marks; + let hasStrong = marks.some((m) => m.type.name === 'strong'); + let hasEm = marks.some((m) => m.type.name === 'em'); + let hasCode = marks.some((m) => m.type.name === 'code'); + let link = marks.find((m) => m.type.name === 'link'); + + if (hasCode) { + text = `\`${text}\``; + } else { + if (hasStrong && hasEm) { + text = `***${text}***`; + } else if (hasStrong) { + text = `**${text}**`; + } else if (hasEm) { + text = `*${text}*`; + } + } + if (link) { + text = link.attrs.title + ? `[${text}](${link.attrs.href} "${link.attrs.title}")` + : `[${text}](${link.attrs.href})`; + } + result += text; + } else if (child.type.name === 'hard_break') { + result += ' \n'; + } else if (child.type.name === 'boxel_card_atom') { + result += `:card[${child.attrs.cardId}]`; + } + }); + return result; +} + +function serializeNode(node: ProseMirrorNode): string { + switch (node.type.name) { + case 'paragraph': + return serializeInlineContent(node); + case 'heading': + return '#'.repeat(node.attrs.level) + ' ' + serializeInlineContent(node); + case 'horizontal_rule': + return '---'; + case 'blockquote': { + let parts: string[] = []; + node.forEach((child, _offset, idx) => { + if (idx > 0) { + parts.push('>'); + } + let serialized = serializeNode(child); + for (let line of serialized.split('\n')) { + parts.push('> ' + line); + } + }); + return parts.join('\n'); + } + case 'code_block': { + let info = node.attrs.info || ''; + let content = node.textContent; + return content + ? `\`\`\`${info}\n${content}\n\`\`\`` + : `\`\`\`${info}\n\`\`\``; + } + case 'bullet_list': { + let items: string[] = []; + node.forEach((item) => { + item.forEach((child, _offset, idx) => { + if (idx === 0) { + items.push('- ' + serializeNode(child)); + } else { + items.push(' ' + serializeNode(child)); + } + }); + }); + return items.join('\n'); + } + case 'ordered_list': { + let items: string[] = []; + let num = node.attrs.order || 1; + node.forEach((item) => { + item.forEach((child, _offset, idx) => { + if (idx === 0) { + items.push(`${num}. ` + serializeNode(child)); + } else { + items.push(' ' + serializeNode(child)); + } + }); + num++; + }); + return items.join('\n'); + } + case 'boxel_card_block': { + let { cardId } = node.attrs; + return `::card[${cardId}]`; + } + default: + return node.textContent || ''; + } +} + +function serializeMarkdown(doc: ProseMirrorNode): string { + let blocks: string[] = []; + doc.forEach((node) => { + blocks.push(serializeNode(node)); + }); + return blocks.join('\n\n'); +} + +// ── Card nodeView factories ─────────────────────────────────────────────── + +export interface CardNodeViewTarget { + element: HTMLElement; + cardId: string; + format: 'atom' | 'embedded'; + kind: 'inline' | 'block'; +} + +function createCardNodeViews( + onChange: (targets: CardNodeViewTarget[]) => void, +): Record Record> { + let targets: CardNodeViewTarget[] = []; + + function notify() { + onChange([...targets]); + } + + return { + boxel_card_atom(node: ProseMirrorNode) { + let dom = document.createElement('span'); + dom.classList.add('boxel-card-atom-view'); + dom.setAttribute('data-card-id', node.attrs.cardId); + dom.contentEditable = 'false'; + + let target: CardNodeViewTarget = { + element: dom, + cardId: node.attrs.cardId, + format: 'atom', + kind: 'inline', + }; + targets.push(target); + notify(); + + return { + dom, + ignoreMutation: () => true, + destroy() { + let idx = targets.indexOf(target); + if (idx >= 0) { + targets.splice(idx, 1); + } + notify(); + }, + }; + }, + + boxel_card_block(node: ProseMirrorNode) { + let dom = document.createElement('div'); + dom.classList.add('boxel-card-block-view'); + dom.setAttribute('data-card-id', node.attrs.cardId); + dom.contentEditable = 'false'; + + let target: CardNodeViewTarget = { + element: dom, + cardId: node.attrs.cardId, + format: 'embedded', + kind: 'block', + }; + targets.push(target); + notify(); + + return { + dom, + ignoreMutation: () => true, + destroy() { + let idx = targets.indexOf(target); + if (idx >= 0) { + targets.splice(idx, 1); + } + notify(); + }, + }; + }, + }; +} + +// ── Slash command plugin ─────────────────────────────────────────────────── + +export interface SlashCommandState { + active: boolean; + query: string; // text after "/" (e.g., "car" when user typed "/car") + from: number; // document position of the "/" character +} + +export interface SlashMenuItem { + id: string; + label: string; + description: string; +} + +const slashCommandPluginKey = new PluginKey( + 'slashCommand', +); + +function createSlashCommandPlugin( + onStateChange: (state: SlashCommandState | null) => void, + onSelectItem: (index: number) => void, + onNavigate: (direction: 'up' | 'down') => void, +): Plugin { + return new Plugin({ + key: slashCommandPluginKey, + state: { + init() { + return null; + }, + apply(tr, prev, _oldState, newState) { + let meta = tr.getMeta(slashCommandPluginKey); + if (meta !== undefined) { + return meta; + } + if (!prev) { + return null; + } + if (tr.docChanged || tr.selectionSet) { + // Re-derive state from document text + let { from: slashFrom } = prev; + let mappedFrom = tr.docChanged + ? tr.mapping.map(slashFrom) + : slashFrom; + let cursorPos = newState.selection.from; + if (cursorPos <= mappedFrom) { + return null; + } + try { + let $pos = tr.doc.resolve(cursorPos); + let startOfNode = $pos.start(); + let offsetInNode = mappedFrom - startOfNode; + let cursorOffset = cursorPos - startOfNode; + if (offsetInNode < 0 || cursorOffset > $pos.parent.content.size) { + return null; + } + let text = $pos.parent.textBetween(offsetInNode, cursorOffset); + if (!text.startsWith('/') || /\s/.test(text.slice(1))) { + return null; + } + let next: SlashCommandState = { + active: true, + query: text.slice(1), + from: mappedFrom, + }; + return next; + } catch { + return null; + } + } + return prev; + }, + }, + props: { + handleTextInput(view, from, _to, text) { + if (text !== '/') { + return false; + } + let $pos = view.state.doc.resolve(from); + let textBefore = $pos.parent.textBetween(0, $pos.parentOffset); + if (textBefore.length === 0 || /\s$/.test(textBefore)) { + // Schedule activation after the "/" is inserted + setTimeout(() => { + let tr = view.state.tr.setMeta(slashCommandPluginKey, { + active: true, + query: '', + from, + } satisfies SlashCommandState); + view.dispatch(tr); + }, 0); + } + return false; + }, + handleKeyDown(view, event) { + let state = slashCommandPluginKey.getState(view.state); + if (!state?.active) { + return false; + } + if (event.key === 'Escape') { + // Dismiss and delete the slash text + let tr = view.state.tr + .delete(state.from, view.state.selection.from) + .setMeta(slashCommandPluginKey, null); + view.dispatch(tr); + return true; + } + if (event.key === 'ArrowUp') { + onNavigate('up'); + return true; + } + if (event.key === 'ArrowDown') { + onNavigate('down'); + return true; + } + if (event.key === 'Enter') { + event.preventDefault(); + onSelectItem(-1); // -1 means "select the currently highlighted item" + return true; + } + return false; + }, + }, + view() { + return { + update(view) { + let state = slashCommandPluginKey.getState(view.state); + onStateChange(state ?? null); + }, + destroy() { + onStateChange(null); + }, + }; + }, + }); +} + +// ── Exported context ─────────────────────────────────────────────────────── + +export interface ProseMirrorContext { + schema: typeof schema; + EditorState: typeof EditorState; + EditorView: typeof EditorView; + keymap: typeof keymap; + baseKeymap: typeof baseKeymap; + history: typeof history; + undo: typeof undo; + redo: typeof redo; + toggleMark: typeof toggleMark; + setBlockType: typeof setBlockType; + wrapIn: typeof wrapIn; + lift: typeof lift; + wrapInList: typeof wrapInList; + splitListItem: typeof splitListItem; + liftListItem: typeof liftListItem; + sinkListItem: typeof sinkListItem; + parseMarkdown: (text: string) => ProseMirrorNode; + serializeMarkdown: (doc: ProseMirrorNode) => string; + createCardNodeViews: typeof createCardNodeViews; + createSlashCommandPlugin: typeof createSlashCommandPlugin; + slashCommandPluginKey: typeof slashCommandPluginKey; +} + +const prosemirrorContext: ProseMirrorContext = { + schema, + EditorState, + EditorView, + keymap, + baseKeymap, + history, + undo, + redo, + toggleMark, + setBlockType, + wrapIn, + lift, + wrapInList, + splitListItem, + liftListItem, + sinkListItem, + parseMarkdown, + serializeMarkdown, + createCardNodeViews, + createSlashCommandPlugin, + slashCommandPluginKey, +}; + +export default prosemirrorContext; diff --git a/packages/host/app/routes/application.ts b/packages/host/app/routes/application.ts index 8783d49b405..b22ec062e3a 100644 --- a/packages/host/app/routes/application.ts +++ b/packages/host/app/routes/application.ts @@ -41,6 +41,14 @@ export default class Application extends Route { let mod = await import('mermaid'); return mod.default; }; + // Lazy-load ProseMirror for WYSIWYG editing in RichMarkdownField. + // The base package's ProseMirrorEditor component calls this via globalThis. + (globalThis as any).__loadProseMirror ??= async () => { + let mod = await import( + '@cardstack/host/lib/prosemirror-context' + ); + return mod.default; + }; } } @@ -50,6 +58,7 @@ export default class Application extends Route { delete (globalThis as any).__loadMonacoForMarkdown; delete (globalThis as any).__loadKatex; delete (globalThis as any).__loadMermaid; + delete (globalThis as any).__loadProseMirror; } } } diff --git a/packages/host/package.json b/packages/host/package.json index 83e7eda3d72..10677cb640c 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -173,6 +173,13 @@ "prettier": "catalog:", "prettier-plugin-ember-template-tag": "catalog:", "process": "catalog:", + "prosemirror-commands": "catalog:", + "prosemirror-history": "catalog:", + "prosemirror-keymap": "catalog:", + "prosemirror-model": "catalog:", + "prosemirror-schema-list": "catalog:", + "prosemirror-state": "catalog:", + "prosemirror-view": "catalog:", "qs": "catalog:", "qunit": "catalog:", "qunit-dom": "catalog:", diff --git a/packages/host/tests/helpers/base-realm.ts b/packages/host/tests/helpers/base-realm.ts index 7e1073ebb40..265262224ef 100644 --- a/packages/host/tests/helpers/base-realm.ts +++ b/packages/host/tests/helpers/base-realm.ts @@ -21,6 +21,7 @@ import type * as RealmFieldModule from 'https://cardstack.com/base/realm'; import type * as SkillModule from 'https://cardstack.com/base/skill'; import type * as StringFieldModule from 'https://cardstack.com/base/string'; import type * as SystemCardModule from 'https://cardstack.com/base/system-card'; +import type * as RichMarkdownModule from 'https://cardstack.com/base/rich-markdown'; import type * as TextAreaFieldModule from 'https://cardstack.com/base/text-area'; type StringField = (typeof StringFieldModule)['default']; @@ -62,6 +63,9 @@ let TextAreaField: TextAreaField; type RealmField = (typeof RealmFieldModule)['default']; let RealmField: RealmField; +type RichMarkdownField = (typeof RichMarkdownModule)['RichMarkdownField']; +let RichMarkdownField: RichMarkdownField; + type PhoneNumberField = (typeof PhoneNumberFieldModule)['default']; let PhoneNumberField: PhoneNumberField; @@ -178,6 +182,12 @@ async function initialize() { await loader.import(`${baseRealm.url}realm`) ).default; + RichMarkdownField = ( + await loader.import( + `${baseRealm.url}rich-markdown`, + ) + ).RichMarkdownField; + PhoneNumberField = ( await loader.import( `${baseRealm.url}phone-number`, @@ -266,6 +276,7 @@ export { MarkdownField, TextAreaField, RealmField, + RichMarkdownField, PhoneNumberField, CardsGrid, SystemCard, diff --git a/packages/host/tests/integration/components/prosemirror-editor-test.gts b/packages/host/tests/integration/components/prosemirror-editor-test.gts new file mode 100644 index 00000000000..c8b8aaaced0 --- /dev/null +++ b/packages/host/tests/integration/components/prosemirror-editor-test.gts @@ -0,0 +1,1097 @@ +import { render, waitFor, settled } from '@ember/test-helpers'; +import { tracked } from '@glimmer/tracking'; +import { module, test } from 'qunit'; + +import { setupRenderingTest } from '../../helpers/setup'; + +// Import the prosemirror-context module directly from host — this is +// the module that gets lazy-loaded in production via globalThis.__loadProseMirror. +import pmContext from '@cardstack/host/lib/prosemirror-context'; + +// We can't import ProseMirrorEditor from @cardstack/base (it's served +// through the realm, not npm). Instead we render a thin wrapper that +// exercises the same lazy-load path the real component uses. + +module('Integration | prosemirror-context', function (hooks) { + setupRenderingTest(hooks); + + // ── Schema tests ── + + test('schema has expected node types', function (assert) { + let { schema } = pmContext; + let nodeNames = Object.keys(schema.nodes); + + assert.true(nodeNames.includes('doc'), 'has doc'); + assert.true(nodeNames.includes('paragraph'), 'has paragraph'); + assert.true(nodeNames.includes('heading'), 'has heading'); + assert.true(nodeNames.includes('blockquote'), 'has blockquote'); + assert.true(nodeNames.includes('code_block'), 'has code_block'); + assert.true(nodeNames.includes('bullet_list'), 'has bullet_list'); + assert.true(nodeNames.includes('ordered_list'), 'has ordered_list'); + assert.true(nodeNames.includes('list_item'), 'has list_item'); + assert.true(nodeNames.includes('horizontal_rule'), 'has horizontal_rule'); + assert.true(nodeNames.includes('hard_break'), 'has hard_break'); + assert.true(nodeNames.includes('text'), 'has text'); + assert.true(nodeNames.includes('boxel_card_atom'), 'has boxel_card_atom'); + assert.true(nodeNames.includes('boxel_card_block'), 'has boxel_card_block'); + }); + + test('schema has expected mark types', function (assert) { + let { schema } = pmContext; + let markNames = Object.keys(schema.marks); + + assert.true(markNames.includes('strong'), 'has strong'); + assert.true(markNames.includes('em'), 'has em'); + assert.true(markNames.includes('code'), 'has code'); + assert.true(markNames.includes('link'), 'has link'); + }); + + // ── Parse tests ── + + test('parseMarkdown: heading', function (assert) { + let doc = pmContext.parseMarkdown('# Hello World'); + let firstChild = doc.firstChild; + assert.strictEqual(firstChild?.type.name, 'heading', 'parses as heading'); + assert.strictEqual(firstChild?.attrs.level, 1, 'heading level is 1'); + assert.strictEqual( + firstChild?.textContent, + 'Hello World', + 'heading text content', + ); + }); + + test('parseMarkdown: paragraph with inline formatting', function (assert) { + let doc = pmContext.parseMarkdown('Some **bold** and *italic* text.'); + let para = doc.firstChild; + assert.strictEqual(para?.type.name, 'paragraph', 'parses as paragraph'); + + let hasStrong = false; + let hasEm = false; + para?.descendants((node) => { + if (node.isText) { + node.marks.forEach((mark) => { + if (mark.type.name === 'strong') hasStrong = true; + if (mark.type.name === 'em') hasEm = true; + }); + } + return true; + }); + assert.true(hasStrong, 'has strong mark'); + assert.true(hasEm, 'has em mark'); + }); + + test('parseMarkdown: bullet list', function (assert) { + let doc = pmContext.parseMarkdown('- Item one\n- Item two\n- Item three'); + let list = doc.firstChild; + assert.strictEqual( + list?.type.name, + 'bullet_list', + 'parses as bullet_list', + ); + assert.strictEqual(list?.childCount, 3, 'has 3 list items'); + }); + + test('parseMarkdown: ordered list', function (assert) { + let doc = pmContext.parseMarkdown('1. First\n2. Second\n3. Third'); + let list = doc.firstChild; + assert.strictEqual( + list?.type.name, + 'ordered_list', + 'parses as ordered_list', + ); + assert.strictEqual(list?.childCount, 3, 'has 3 list items'); + }); + + test('parseMarkdown: code block', function (assert) { + let doc = pmContext.parseMarkdown('```typescript\nconst x = 1;\n```'); + let block = doc.firstChild; + assert.strictEqual( + block?.type.name, + 'code_block', + 'parses as code_block', + ); + assert.strictEqual( + block?.textContent, + 'const x = 1;', + 'code block content', + ); + }); + + test('parseMarkdown: blockquote', function (assert) { + let doc = pmContext.parseMarkdown('> This is a quote'); + let bq = doc.firstChild; + assert.strictEqual(bq?.type.name, 'blockquote', 'parses as blockquote'); + assert.strictEqual( + bq?.firstChild?.textContent, + 'This is a quote', + 'blockquote text content', + ); + }); + + test('parseMarkdown: card atom inline', function (assert) { + let doc = pmContext.parseMarkdown( + 'See :card[./Author/alice] for details.', + ); + let para = doc.firstChild; + assert.strictEqual(para?.type.name, 'paragraph', 'wraps in paragraph'); + + let hasAtom = false; + para?.descendants((node) => { + if (node.type.name === 'boxel_card_atom') { + hasAtom = true; + assert.strictEqual( + node.attrs.cardId, + './Author/alice', + 'card atom has correct cardId', + ); + } + return true; + }); + assert.true(hasAtom, 'contains card atom node'); + }); + + test('parseMarkdown: card block', function (assert) { + let doc = pmContext.parseMarkdown('::card[./Author/alice]'); + let block = doc.firstChild; + assert.strictEqual( + block?.type.name, + 'boxel_card_block', + 'parses as boxel_card_block', + ); + assert.strictEqual( + block?.attrs.cardId, + './Author/alice', + 'card block has correct cardId', + ); + }); + + test('parseMarkdown: horizontal rule', function (assert) { + let doc = pmContext.parseMarkdown('Before\n\n---\n\nAfter'); + let hasHr = false; + doc.descendants((node) => { + if (node.type.name === 'horizontal_rule') hasHr = true; + return true; + }); + assert.true(hasHr, 'contains horizontal_rule node'); + }); + + test('parseMarkdown: empty content', function (assert) { + let doc = pmContext.parseMarkdown(''); + assert.strictEqual(doc.type.name, 'doc', 'returns a doc node'); + assert.strictEqual( + doc.firstChild?.type.name, + 'paragraph', + 'has an empty paragraph', + ); + }); + + // ── Serialize tests ── + + test('serializeMarkdown: heading', function (assert) { + let doc = pmContext.parseMarkdown('# Hello World'); + let result = pmContext.serializeMarkdown(doc); + assert.strictEqual(result.trim(), '# Hello World'); + }); + + test('serializeMarkdown: paragraph with formatting', function (assert) { + let doc = pmContext.parseMarkdown('Some **bold** and *italic* text.'); + let result = pmContext.serializeMarkdown(doc); + assert.true(result.includes('**bold**'), 'preserves bold'); + assert.true(result.includes('*italic*'), 'preserves italic'); + }); + + test('serializeMarkdown: bullet list', function (assert) { + let doc = pmContext.parseMarkdown('- Item one\n- Item two'); + let result = pmContext.serializeMarkdown(doc); + assert.true(result.includes('- Item one'), 'preserves first item'); + assert.true(result.includes('- Item two'), 'preserves second item'); + }); + + test('serializeMarkdown: code block', function (assert) { + let doc = pmContext.parseMarkdown('```\nconst x = 1;\n```'); + let result = pmContext.serializeMarkdown(doc); + assert.true(result.includes('const x = 1;'), 'preserves code content'); + assert.true(result.includes('```'), 'preserves code fences'); + }); + + test('serializeMarkdown: blockquote', function (assert) { + let doc = pmContext.parseMarkdown('> This is a quote'); + let result = pmContext.serializeMarkdown(doc); + assert.true(result.includes('> This is a quote'), 'preserves blockquote'); + }); + + test('serializeMarkdown: card atom', function (assert) { + let doc = pmContext.parseMarkdown( + 'See :card[./Author/alice] for details.', + ); + let result = pmContext.serializeMarkdown(doc); + assert.true( + result.includes(':card[./Author/alice]'), + 'preserves card atom syntax', + ); + }); + + test('serializeMarkdown: card block', function (assert) { + let doc = pmContext.parseMarkdown('::card[./Author/alice]'); + let result = pmContext.serializeMarkdown(doc); + assert.true( + result.includes('::card[./Author/alice]'), + 'preserves card block syntax', + ); + }); + + test('round-trip: complex document', function (assert) { + let input = [ + '# Title', + '', + 'A paragraph with **bold** and *italic*.', + '', + '- Item one', + '- Item two', + '', + '> A quote', + '', + '```', + 'code here', + '```', + '', + '::card[./SomeCard/1]', + ].join('\n'); + + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + + assert.true(output.includes('# Title'), 'preserves heading'); + assert.true(output.includes('**bold**'), 'preserves bold'); + assert.true(output.includes('*italic*'), 'preserves italic'); + assert.true(output.includes('- Item one'), 'preserves list'); + assert.true(output.includes('> A quote'), 'preserves blockquote'); + assert.true(output.includes('code here'), 'preserves code'); + assert.true( + output.includes('::card[./SomeCard/1]'), + 'preserves card block', + ); + }); + + // ── Round-trip: standard markdown elements ── + + test('round-trip: heading levels 1-6', function (assert) { + for (let level = 1; level <= 6; level++) { + let prefix = '#'.repeat(level); + let input = `${prefix} Heading Level ${level}`; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input, `h${level} round-trips`); + } + }); + + test('round-trip: plain paragraph', function (assert) { + let input = 'Just a plain paragraph.'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + test('round-trip: bold text', function (assert) { + let input = 'Some **bold** text.'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + test('round-trip: italic text', function (assert) { + let input = 'Some *italic* text.'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + test('round-trip: bold italic text', function (assert) { + let input = 'Some ***bold italic*** text.'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + test('round-trip: inline code', function (assert) { + let input = 'Use the `console.log` function.'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + test('round-trip: link', function (assert) { + let input = 'Visit [Example](https://example.com) for more.'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + test('round-trip: bullet list', function (assert) { + let input = '- First item\n- Second item\n- Third item'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + test('round-trip: ordered list', function (assert) { + let input = '1. First item\n2. Second item\n3. Third item'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + test('round-trip: ordered list with custom start', function (assert) { + let input = '5. Fifth item\n6. Sixth item'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + test('round-trip: blockquote', function (assert) { + let input = '> This is a quoted paragraph.'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + test('round-trip: code block without language', function (assert) { + let input = '```\nconst x = 1;\n```'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + test('round-trip: code block with language', function (assert) { + let input = '```typescript\nconst x: number = 1;\n```'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + test('round-trip: horizontal rule', function (assert) { + let input = 'Before\n\n---\n\nAfter'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + test('round-trip: multiple paragraphs', function (assert) { + let input = 'First paragraph.\n\nSecond paragraph.'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + test('round-trip: empty code block', function (assert) { + let input = '```\n```'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + // ── Round-trip: card references ── + + test('round-trip: inline card with relative URL', function (assert) { + let input = 'See :card[./Author/alice] for details.'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + test('round-trip: inline card with absolute URL', function (assert) { + let input = + 'See :card[https://example.com/Author/alice] for details.'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + test('round-trip: block card with relative URL', function (assert) { + let input = '::card[./Author/alice]'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + test('round-trip: block card with absolute URL', function (assert) { + let input = '::card[https://example.com/Author/alice]'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + // ── Round-trip: edge cases ── + + test('round-trip: adjacent inline cards', function (assert) { + let input = ':card[./Author/alice]:card[./Author/bob]'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + test('round-trip: card atom inside list item', function (assert) { + let input = + '- See :card[./Author/alice] here\n- And :card[./Author/bob] there'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + test('round-trip: card block after heading', function (assert) { + let input = '# Authors\n\n::card[./Author/alice]'; + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + test('round-trip: mixed content document', function (assert) { + let input = [ + '# Document Title', + '', + 'A paragraph with **bold**, *italic*, and `code`.', + '', + '- List item with :card[./Card/1]', + '- Another item', + '', + '> A blockquote', + '', + '```javascript', + 'console.log("hello");', + '```', + '', + '::card[./SomeCard/1]', + ].join('\n'); + + let doc = pmContext.parseMarkdown(input); + let output = pmContext.serializeMarkdown(doc); + assert.strictEqual(output, input); + }); + + // ── Parse: label auto-derivation ── + + test('parseMarkdown: card atom label derived from URL path', function (assert) { + let doc = pmContext.parseMarkdown('See :card[./Author/alice] here.'); + let atom: any = null; + doc.descendants((node) => { + if (node.type.name === 'boxel_card_atom') atom = node; + return true; + }); + assert.strictEqual(atom?.attrs.cardId, './Author/alice'); + assert.strictEqual( + atom?.attrs.label, + 'alice', + 'label derived from last path segment', + ); + }); + + test('parseMarkdown: card atom label from absolute URL', function (assert) { + let doc = pmContext.parseMarkdown( + ':card[https://example.com/Author/alice]', + ); + let atom: any = null; + doc.descendants((node) => { + if (node.type.name === 'boxel_card_atom') atom = node; + return true; + }); + assert.strictEqual( + atom?.attrs.label, + 'alice', + 'label derived from last URL segment', + ); + }); + + // ── EditorState / EditorView integration ── + + test('EditorState can be created from parsed document', function (assert) { + let doc = pmContext.parseMarkdown('Hello world'); + let state = pmContext.EditorState.create({ doc }); + + assert.ok(state, 'state is created'); + assert.strictEqual( + state.doc.firstChild?.textContent, + 'Hello world', + 'state contains the parsed document', + ); + }); + + test('EditorView mounts into a DOM element', async function (assert) { + let doc = pmContext.parseMarkdown('# Test Heading'); + let state = pmContext.EditorState.create({ + doc, + plugins: [ + pmContext.keymap(pmContext.baseKeymap), + pmContext.history(), + ], + }); + + await render(); + + let mountEl = document.querySelector('#pm-mount') as HTMLElement; + assert.ok(mountEl, 'mount element exists'); + + let view = new pmContext.EditorView(mountEl, { state }); + + assert.ok( + mountEl.querySelector('.ProseMirror'), + 'ProseMirror view mounts into element', + ); + assert.ok( + mountEl.querySelector('.ProseMirror h1'), + 'heading renders in the view', + ); + assert.strictEqual( + mountEl.querySelector('.ProseMirror h1')?.textContent, + 'Test Heading', + 'heading text content is correct', + ); + + view.destroy(); + }); + + test('EditorView renders card atom placeholder DOM', async function (assert) { + let doc = pmContext.parseMarkdown( + 'See :card[./Author/alice] for details.', + ); + let state = pmContext.EditorState.create({ doc }); + + await render(); + + let mountEl = document.querySelector('#pm-mount') as HTMLElement; + let view = new pmContext.EditorView(mountEl, { state }); + + assert.ok( + mountEl.querySelector('.boxel-card-atom'), + 'card atom placeholder renders', + ); + + view.destroy(); + }); + + test('EditorView renders card block placeholder DOM', async function (assert) { + let doc = pmContext.parseMarkdown('::card[./Author/alice]'); + let state = pmContext.EditorState.create({ doc }); + + await render(); + + let mountEl = document.querySelector('#pm-mount') as HTMLElement; + let view = new pmContext.EditorView(mountEl, { state }); + + assert.ok( + mountEl.querySelector('.boxel-card-block'), + 'card block placeholder renders', + ); + + view.destroy(); + }); + + // ── Card nodeView tests ── + + test('createCardNodeViews registers atom targets', function (assert) { + let receivedTargets: any[] = []; + let nodeViews = pmContext.createCardNodeViews((targets: any[]) => { + receivedTargets = targets; + }); + + assert.ok(nodeViews.boxel_card_atom, 'has boxel_card_atom nodeView'); + assert.ok(nodeViews.boxel_card_block, 'has boxel_card_block nodeView'); + + let atomNode = pmContext.schema.nodes.boxel_card_atom.create({ + cardId: './Author/alice', + label: 'alice', + }); + let nv = nodeViews.boxel_card_atom(atomNode); + + assert.ok(nv.dom, 'nodeView has dom element'); + assert.strictEqual( + nv.dom.tagName, + 'SPAN', + 'atom nodeView uses span element', + ); + assert.strictEqual( + nv.dom.getAttribute('data-card-id'), + './Author/alice', + 'dom has data-card-id attribute', + ); + assert.strictEqual( + nv.dom.classList.contains('boxel-card-atom-view'), + true, + 'dom has boxel-card-atom-view class', + ); + assert.strictEqual(receivedTargets.length, 1, 'one target registered'); + assert.strictEqual( + receivedTargets[0].cardId, + './Author/alice', + 'target has correct cardId', + ); + assert.strictEqual( + receivedTargets[0].format, + 'atom', + 'target has atom format', + ); + assert.strictEqual( + receivedTargets[0].kind, + 'inline', + 'target has inline kind', + ); + + nv.destroy(); + assert.strictEqual( + receivedTargets.length, + 0, + 'target unregistered on destroy', + ); + }); + + test('createCardNodeViews registers block targets', function (assert) { + let receivedTargets: any[] = []; + let nodeViews = pmContext.createCardNodeViews((targets: any[]) => { + receivedTargets = targets; + }); + + let blockNode = pmContext.schema.nodes.boxel_card_block.create({ + cardId: './Author/alice', + }); + let nv = nodeViews.boxel_card_block(blockNode); + + assert.ok(nv.dom, 'nodeView has dom element'); + assert.strictEqual( + nv.dom.tagName, + 'DIV', + 'block nodeView uses div element', + ); + assert.strictEqual( + nv.dom.getAttribute('data-card-id'), + './Author/alice', + 'dom has data-card-id attribute', + ); + assert.strictEqual( + receivedTargets.length, + 1, + 'one target registered', + ); + assert.strictEqual( + receivedTargets[0].format, + 'embedded', + 'target has embedded format', + ); + assert.strictEqual( + receivedTargets[0].kind, + 'block', + 'target has block kind', + ); + + nv.destroy(); + assert.strictEqual( + receivedTargets.length, + 0, + 'target unregistered on destroy', + ); + }); + + test('createCardNodeViews tracks multiple targets', function (assert) { + let receivedTargets: any[] = []; + let nodeViews = pmContext.createCardNodeViews((targets: any[]) => { + receivedTargets = targets; + }); + + let atom1 = pmContext.schema.nodes.boxel_card_atom.create({ + cardId: './Card/1', + label: '1', + }); + let atom2 = pmContext.schema.nodes.boxel_card_atom.create({ + cardId: './Card/2', + label: '2', + }); + let block1 = pmContext.schema.nodes.boxel_card_block.create({ + cardId: './Card/3', + }); + + let nv1 = nodeViews.boxel_card_atom(atom1); + let nv2 = nodeViews.boxel_card_atom(atom2); + let nv3 = nodeViews.boxel_card_block(block1); + + assert.strictEqual(receivedTargets.length, 3, 'three targets registered'); + + nv2.destroy(); + assert.strictEqual( + receivedTargets.length, + 2, + 'two targets after destroying middle', + ); + assert.strictEqual( + receivedTargets[0].cardId, + './Card/1', + 'first target preserved', + ); + assert.strictEqual( + receivedTargets[1].cardId, + './Card/3', + 'third target preserved', + ); + + nv1.destroy(); + nv3.destroy(); + assert.strictEqual(receivedTargets.length, 0, 'all targets cleared'); + }); + + test('EditorView with nodeViews renders card containers', async function (assert) { + let doc = pmContext.parseMarkdown( + 'Text with :card[./Author/alice] and\n\n::card[./Post/1]', + ); + let state = pmContext.EditorState.create({ doc }); + + await render(); + + let mountEl = document.querySelector('#pm-mount') as HTMLElement; + let targets: any[] = []; + let nodeViews = pmContext.createCardNodeViews((t: any[]) => { + targets = t; + }); + + let view = new pmContext.EditorView(mountEl, { state, nodeViews }); + + assert.ok( + mountEl.querySelector('.boxel-card-atom-view'), + 'card atom nodeView container renders', + ); + assert.ok( + mountEl.querySelector('.boxel-card-block-view'), + 'card block nodeView container renders', + ); + assert.strictEqual(targets.length, 2, 'two targets registered'); + assert.strictEqual( + targets[0].cardId, + './Author/alice', + 'atom target has correct cardId', + ); + assert.strictEqual( + targets[1].cardId, + './Post/1', + 'block target has correct cardId', + ); + + view.destroy(); + assert.strictEqual( + targets.length, + 0, + 'targets cleared after view destroy', + ); + }); + + test('nodeView ignoreMutation returns true', function (assert) { + let nodeViews = pmContext.createCardNodeViews(() => {}); + let atomNode = pmContext.schema.nodes.boxel_card_atom.create({ + cardId: './Card/1', + label: '1', + }); + let nv = nodeViews.boxel_card_atom(atomNode); + + assert.true( + nv.ignoreMutation(), + 'ignoreMutation returns true to prevent ProseMirror from handling DOM mutations in card content', + ); + + nv.destroy(); + }); + + // ── Slash command plugin tests ── + + test('createSlashCommandPlugin activates on "/" via setMeta', async function (assert) { + let doc = pmContext.parseMarkdown(''); + let stateChanges: any[] = []; + let slashPlugin = pmContext.createSlashCommandPlugin( + (state: any) => { stateChanges.push(state); }, + () => {}, + () => {}, + ); + let state = pmContext.EditorState.create({ + doc, + plugins: [slashPlugin], + }); + + await render(); + let mountEl = document.querySelector('#pm-mount') as HTMLElement; + let view = new pmContext.EditorView(mountEl, { state }); + + // Activate via setMeta (simulates what handleTextInput does internally) + let from = view.state.selection.from; + let tr = view.state.tr + .insertText('/', from, from) + .setMeta(pmContext.slashCommandPluginKey, { + active: true, + query: '', + from, + }); + view.dispatch(tr); + + let pluginState = pmContext.slashCommandPluginKey.getState(view.state); + assert.ok( + pluginState?.active, + 'slash command plugin activates via setMeta', + ); + assert.strictEqual( + pluginState?.query, + '', + 'query is empty after just "/"', + ); + + // The view callback should have been called + assert.ok( + stateChanges.length > 0, + 'onStateChange callback was called', + ); + + view.destroy(); + }); + + test('createSlashCommandPlugin tracks query as user types', async function (assert) { + let doc = pmContext.parseMarkdown(''); + let slashPlugin = pmContext.createSlashCommandPlugin( + () => {}, + () => {}, + () => {}, + ); + let state = pmContext.EditorState.create({ + doc, + plugins: [slashPlugin], + }); + + await render(); + let mountEl = document.querySelector('#pm-mount') as HTMLElement; + let view = new pmContext.EditorView(mountEl, { state }); + + // Insert "/" and activate via setMeta + let from = view.state.selection.from; + let tr = view.state.tr + .insertText('/', from, from) + .setMeta(pmContext.slashCommandPluginKey, { + active: true, + query: '', + from, + }); + view.dispatch(tr); + + // Type "car" after the "/" + let pos = view.state.selection.from; + tr = view.state.tr.insertText('car', pos, pos); + view.dispatch(tr); + + let pluginState = pmContext.slashCommandPluginKey.getState(view.state); + assert.strictEqual( + pluginState?.query, + 'car', + 'query tracks typed text after "/"', + ); + + view.destroy(); + }); + + test('createSlashCommandPlugin deactivates on Escape', async function (assert) { + let doc = pmContext.parseMarkdown(''); + let slashPlugin = pmContext.createSlashCommandPlugin( + () => {}, + () => {}, + () => {}, + ); + let state = pmContext.EditorState.create({ + doc, + plugins: [slashPlugin], + }); + + await render(); + let mountEl = document.querySelector('#pm-mount') as HTMLElement; + let view = new pmContext.EditorView(mountEl, { state }); + + // Insert "/" and activate + let from = view.state.selection.from; + let tr = view.state.tr + .insertText('/', from, from) + .setMeta(pmContext.slashCommandPluginKey, { + active: true, + query: '', + from, + }); + view.dispatch(tr); + + assert.ok( + pmContext.slashCommandPluginKey.getState(view.state)?.active, + 'plugin is active before Escape', + ); + + // Simulate Escape key — ProseMirror's handleKeyDown fires on focus+dispatch + view.dom.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + })); + + let pluginState = pmContext.slashCommandPluginKey.getState(view.state); + assert.notOk( + pluginState?.active, + 'plugin deactivates after Escape', + ); + + view.destroy(); + }); + + test('createSlashCommandPlugin calls onNavigate on ArrowUp/Down', async function (assert) { + let doc = pmContext.parseMarkdown(''); + let navigations: string[] = []; + let slashPlugin = pmContext.createSlashCommandPlugin( + () => {}, + () => {}, + (direction: string) => { navigations.push(direction); }, + ); + let state = pmContext.EditorState.create({ + doc, + plugins: [slashPlugin], + }); + + await render(); + let mountEl = document.querySelector('#pm-mount') as HTMLElement; + let view = new pmContext.EditorView(mountEl, { state }); + + // Activate slash command + let from = view.state.selection.from; + let tr = view.state.tr + .insertText('/', from, from) + .setMeta(pmContext.slashCommandPluginKey, { + active: true, + query: '', + from, + }); + view.dispatch(tr); + + // Simulate ArrowDown and ArrowUp + view.dom.dispatchEvent(new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + cancelable: true, + })); + view.dom.dispatchEvent(new KeyboardEvent('keydown', { + key: 'ArrowUp', + bubbles: true, + cancelable: true, + })); + + assert.deepEqual( + navigations, + ['down', 'up'], + 'onNavigate called with correct directions', + ); + + view.destroy(); + }); + + test('createSlashCommandPlugin calls onSelectItem on Enter', async function (assert) { + let doc = pmContext.parseMarkdown(''); + let selections: number[] = []; + let slashPlugin = pmContext.createSlashCommandPlugin( + () => {}, + (index: number) => { selections.push(index); }, + () => {}, + ); + let state = pmContext.EditorState.create({ + doc, + plugins: [slashPlugin], + }); + + await render(); + let mountEl = document.querySelector('#pm-mount') as HTMLElement; + let view = new pmContext.EditorView(mountEl, { state }); + + // Activate slash command + let from = view.state.selection.from; + let tr = view.state.tr + .insertText('/', from, from) + .setMeta(pmContext.slashCommandPluginKey, { + active: true, + query: '', + from, + }); + view.dispatch(tr); + + // Simulate Enter + view.dom.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + })); + + assert.deepEqual( + selections, + [-1], + 'onSelectItem called with -1 (select current item)', + ); + + view.destroy(); + }); + + test('card node insertion creates correct inline node', function (assert) { + let doc = pmContext.parseMarkdown('Hello world'); + let state = pmContext.EditorState.create({ doc }); + + // Insert an inline card atom at position 6 (after "Hello ") + let node = pmContext.schema.nodes.boxel_card_atom.create({ + cardId: './Author/alice', + label: 'alice', + }); + let tr = state.tr.insert(6, node); + let newState = state.apply(tr); + let markdown = pmContext.serializeMarkdown(newState.doc); + + assert.ok( + markdown.includes(':card[./Author/alice]'), + 'inline card reference serialized correctly', + ); + }); + + test('card node insertion creates correct block node', function (assert) { + let doc = pmContext.parseMarkdown('Hello world'); + let state = pmContext.EditorState.create({ doc }); + + // Insert a block card after the paragraph + let node = pmContext.schema.nodes.boxel_card_block.create({ + cardId: './Post/1', + }); + // Position after the paragraph end + let insertPos = state.doc.content.size; + let tr = state.tr.insert(insertPos, node); + let newState = state.apply(tr); + let markdown = pmContext.serializeMarkdown(newState.doc); + + assert.ok( + markdown.includes('::card[./Post/1]'), + 'block card reference serialized correctly', + ); + assert.ok( + markdown.includes('Hello world'), + 'original content preserved', + ); + }); + + // ── Lazy-loading via globalThis (component pattern) ── + + test('globalThis.__loadProseMirror loader works', async function (assert) { + let originalLoader = (globalThis as any).__loadProseMirror; + + (globalThis as any).__loadProseMirror = async () => { + let mod = await import('@cardstack/host/lib/prosemirror-context'); + return mod.default; + }; + + try { + let loadProseMirror = (globalThis as any).__loadProseMirror; + let pm = await loadProseMirror(); + + assert.ok(pm.schema, 'loaded context has schema'); + assert.ok(pm.EditorState, 'loaded context has EditorState'); + assert.ok(pm.EditorView, 'loaded context has EditorView'); + assert.ok(pm.parseMarkdown, 'loaded context has parseMarkdown'); + assert.ok(pm.serializeMarkdown, 'loaded context has serializeMarkdown'); + } finally { + (globalThis as any).__loadProseMirror = originalLoader; + } + }); +}); diff --git a/packages/host/tests/integration/components/rich-markdown-field-test.gts b/packages/host/tests/integration/components/rich-markdown-field-test.gts new file mode 100644 index 00000000000..97bb4a73ab9 --- /dev/null +++ b/packages/host/tests/integration/components/rich-markdown-field-test.gts @@ -0,0 +1,224 @@ +import type { RenderingTestContext } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import { + PermissionsContextName, + type Permissions, + baseRealm, +} from '@cardstack/runtime-common'; +import type { Loader } from '@cardstack/runtime-common/loader'; + +import { + cleanWhiteSpace, + provideConsumeContext, + setupCardLogs, + setupIntegrationTestRealm, + setupLocalIndexing, +} from '../../helpers'; +import { + setupBaseRealm, + CardDef, + Component, + RichMarkdownField, + contains, + field, +} from '../../helpers/base-realm'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { renderCard } from '../../helpers/render-component'; +import { setupRenderingTest } from '../../helpers/setup'; + +let loader: Loader; + +module('Integration | RichMarkdownField', function (hooks) { + setupRenderingTest(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks); + + setupBaseRealm(hooks); + + hooks.beforeEach(function (this: RenderingTestContext) { + let permissions: Permissions = { + canWrite: true, + canRead: true, + }; + provideConsumeContext(PermissionsContextName, permissions); + loader = getService('loader-service').loader; + }); + setupLocalIndexing(hooks); + + setupCardLogs( + hooks, + async () => await loader.import(`${baseRealm.url}card-api`), + ); + + test('renders markdown as HTML', async function (assert) { + class TestCard extends CardDef { + @field body = contains(RichMarkdownField); + static atom = class Atom extends Component { + + }; + } + + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: { + 'test-card.gts': { TestCard }, + }, + }); + + let card = new TestCard({ + body: new RichMarkdownField({ + content: '# Hello World\n\nSome **bold** text.', + }), + }); + let root = await renderCard(loader, card, 'atom'); + assert.dom(root.querySelector('h1')).hasText('Hello World'); + assert.dom(root.querySelector('strong')).hasText('bold'); + }); + + test('edit template renders ProseMirror editor for content', async function (assert) { + class TestCard extends CardDef { + @field body = contains(RichMarkdownField); + static edit = class Edit extends Component { + + }; + } + + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: { + 'test-card.gts': { TestCard }, + }, + }); + + let card = new TestCard({ + body: new RichMarkdownField({ content: 'Edit me' }), + }); + let root = await renderCard(loader, card, 'edit'); + // ProseMirrorEditor shows a loading state or the editor depending on + // whether the lazy module is available in the test environment + let editor = root.querySelector('[data-test-prosemirror-editor]'); + let loading = root.querySelector('[data-test-prosemirror-loading]'); + assert.ok( + editor || loading, + 'ProseMirror editor or loading state is rendered', + ); + }); + + test('renders with null content without error', async function (assert) { + class TestCard extends CardDef { + @field body = contains(RichMarkdownField); + static edit = class Edit extends Component { + + }; + } + + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: { + 'test-card.gts': { TestCard }, + }, + }); + + let card = new TestCard(); + let root = await renderCard(loader, card, 'edit'); + assert.dom(root).exists('renders without error'); + }); + + test('renders inline :card references as BFM elements', async function (assert) { + class TestCard extends CardDef { + @field body = contains(RichMarkdownField); + static atom = class Atom extends Component { + + }; + } + + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: { + 'test-card.gts': { TestCard }, + }, + }); + + let card = new TestCard({ + body: new RichMarkdownField({ + content: 'See :card[https://example.com/cards/1] for details.', + }), + }); + let root = await renderCard(loader, card, 'atom'); + assert + .dom(root.querySelector('[data-boxel-bfm-inline-ref]')) + .exists('inline card reference is rendered as BFM element'); + assert + .dom(root.querySelector('[data-boxel-bfm-inline-ref]')) + .hasAttribute( + 'data-boxel-bfm-inline-ref', + 'https://example.com/cards/1', + ); + }); + + test('renders block ::card references as BFM elements', async function (assert) { + class TestCard extends CardDef { + @field body = contains(RichMarkdownField); + static atom = class Atom extends Component { + + }; + } + + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: { + 'test-card.gts': { TestCard }, + }, + }); + + let card = new TestCard({ + body: new RichMarkdownField({ + content: '::card[https://example.com/cards/2]\n', + }), + }); + let root = await renderCard(loader, card, 'atom'); + assert + .dom(root.querySelector('[data-boxel-bfm-block-ref]')) + .exists('block card reference is rendered as BFM element'); + assert + .dom(root.querySelector('[data-boxel-bfm-block-ref]')) + .hasAttribute( + 'data-boxel-bfm-block-ref', + 'https://example.com/cards/2', + ); + }); + + test('renders footnotes', async function (assert) { + class TestCard extends CardDef { + @field body = contains(RichMarkdownField); + static atom = class Atom extends Component { + + }; + } + + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: { + 'test-card.gts': { TestCard }, + }, + }); + + let card = new TestCard({ + body: new RichMarkdownField({ + content: + 'Text with a footnote[^1].\n\n[^1]: This is the footnote content.', + }), + }); + let root = await renderCard(loader, card, 'atom'); + assert + .dom(root.querySelector('.footnotes')) + .exists('footnote section is rendered'); + assert.ok( + root.textContent!.includes('This is the footnote content'), + 'footnote content is present', + ); + }); +}); diff --git a/packages/runtime-common/realm-index-query-engine.ts b/packages/runtime-common/realm-index-query-engine.ts index 24a8380d725..8c92d09945c 100644 --- a/packages/runtime-common/realm-index-query-engine.ts +++ b/packages/runtime-common/realm-index-query-engine.ts @@ -444,6 +444,20 @@ export class RealmIndexQueryEngine { return await this.#indexQueryEngine.getFile(url, opts); } + async loadLinksForResource( + resource: LooseCardResource | FileMetaResource, + opts?: Options, + ): Promise<(CardResource | FileMetaResource)[]> { + return await this.loadLinks( + { + realmURL: this.realmURL, + resource, + omit: [...(resource.id ? [resource.id] : [])], + }, + opts, + ); + } + private async populateQueryFields( resource: LooseCardResource | FileMetaResource, realmURL: URL, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25040fae33d..e286e29cec9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -564,6 +564,27 @@ catalogs: process: specifier: ^0.11.10 version: 0.11.10 + prosemirror-commands: + specifier: ^1.7.1 + version: 1.7.1 + prosemirror-history: + specifier: ^1.5.0 + version: 1.5.0 + prosemirror-keymap: + specifier: ^1.2.3 + version: 1.2.3 + prosemirror-model: + specifier: ^1.25.4 + version: 1.25.4 + prosemirror-schema-list: + specifier: ^1.5.1 + version: 1.5.1 + prosemirror-state: + specifier: ^1.4.4 + version: 1.4.4 + prosemirror-view: + specifier: ^1.41.8 + version: 1.41.8 qs: specifier: ^6.13.0 version: 6.14.1 @@ -2222,6 +2243,27 @@ importers: process: specifier: 'catalog:' version: 0.11.10 + prosemirror-commands: + specifier: 'catalog:' + version: 1.7.1 + prosemirror-history: + specifier: 'catalog:' + version: 1.5.0 + prosemirror-keymap: + specifier: 'catalog:' + version: 1.2.3 + prosemirror-model: + specifier: 'catalog:' + version: 1.25.4 + prosemirror-schema-list: + specifier: 'catalog:' + version: 1.5.1 + prosemirror-state: + specifier: 'catalog:' + version: 1.4.4 + prosemirror-view: + specifier: 'catalog:' + version: 1.41.8 qs: specifier: 'catalog:' version: 6.14.1 @@ -12208,6 +12250,9 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + os-locale@5.0.0: resolution: {integrity: sha512-tqZcNEDAIZKBEPnHPlVDvKrp7NzgLi7jRmhKiUoa2NUmhl13FtkAGLUVR+ZsYvApBQdBfYm43A4tXXQ4IrYLBA==} engines: {node: '>=10'} @@ -12757,6 +12802,30 @@ packages: proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + prosemirror-commands@1.7.1: + resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} + + prosemirror-history@1.5.0: + resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + + prosemirror-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + + prosemirror-model@1.25.4: + resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} + + 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-view@1.41.8: + resolution: {integrity: sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -13176,6 +13245,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} @@ -14583,6 +14655,9 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@4.0.0: resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} engines: {node: '>=14'} @@ -26449,6 +26524,8 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 + orderedmap@2.1.1: {} + os-locale@5.0.0: dependencies: execa: 4.1.0 @@ -26964,6 +27041,50 @@ snapshots: retry: 0.12.0 signal-exit: 3.0.7 + prosemirror-commands@1.7.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-history@1.5.0: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + rope-sequence: 1.3.4 + + prosemirror-keymap@1.2.3: + dependencies: + prosemirror-state: 1.4.4 + w3c-keyname: 2.2.8 + + prosemirror-model@1.25.4: + dependencies: + orderedmap: 2.1.1 + + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + prosemirror-transform@1.12.0: + dependencies: + prosemirror-model: 1.25.4 + + prosemirror-view@1.41.8: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -27522,6 +27643,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.55.1 fsevents: 2.3.3 + rope-sequence@1.3.4: {} + roughjs@4.6.6: dependencies: hachure-fill: 0.5.2 @@ -29191,6 +29314,8 @@ snapshots: vscode-uri@3.1.0: {} + w3c-keyname@2.2.8: {} + w3c-xmlserializer@4.0.0: dependencies: xml-name-validator: 4.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 224b82dbfc5..c9dc630bc6c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -206,6 +206,13 @@ catalog: prettier: ^3.6.2 prettier-plugin-ember-template-tag: ^2.0.0 process: ^0.11.10 + prosemirror-commands: ^1.7.1 + prosemirror-history: ^1.5.0 + prosemirror-keymap: ^1.2.3 + prosemirror-model: ^1.25.4 + prosemirror-schema-list: ^1.5.1 + prosemirror-state: ^1.4.4 + prosemirror-view: ^1.41.8 qs: ^6.13.0 qunit: ^2.24.1 qunit-dom: ^3.5.0