import { pickedCompletion, startCompletion, type Completion, type CompletionSource, } from '@codemirror/autocomplete'; import { EditorState, MapMode, StateEffect, StateField, type Extension, type StateEffectType, } from '@codemirror/state'; import type { EditorView } from '@codemirror/view'; import type { SlashCommandItem } from '@plannotator/atomic-editor'; import { planEmbedInsert } from '@plannotator/core/embed-insert'; /** What an embed can point at. The union grows as new media kinds ship. */ export type EmbedKind = 'html'; /** One embeddable target supplied by the host. */ export interface EmbedTarget { /** Media kind represented by this target. */ readonly kind: EmbedKind; /** Host-meaningful target identifier or path. */ readonly path: string; /** Display title. Blank and absent titles fall back to {@link path}. */ readonly title?: string | null; } /** Per-editor host callbacks for the `/embed` picker. */ export interface EmbedPickerConfig { /** Live read of embeddable targets. Dynamic data belongs behind this callback. */ readonly getTargets: () => readonly EmbedTarget[]; /** Build the exact host-owned embed line for a picked target. */ readonly buildInsertLine: (target: EmbedTarget) => string; /** * Optionally upload a target. Resolve with a target to insert it, or `null` * when the user cancels. The host owns error presentation. */ readonly uploadTarget?: (kind: EmbedKind) => Promise; /** Return an optional informational row for the current document text. */ readonly getNotice?: (docBody: string) => string | null; /** * Host copy for the three rows that name the target type (0.45.1). A host * that feeds the picker more than HTML files passes its own wording; each * absent key keeps the built-in text, so Plannotator's menu is unchanged. */ readonly labels?: { /** The upload row. Default: "Upload HTML..." */ readonly upload?: string; /** The row shown when there are no targets at all. Default: "No HTML files in this workspace" */ readonly empty?: string; /** The row shown when the query matches nothing. Default: "No HTML files match “”" */ readonly noMatch?: (query: string) => string; }; } type IconCompletion = Completion & { readonly slashCommandIcon?: string; }; type UploadAnchor = { readonly from: number; readonly to: number; readonly text: string; }; type UploadState = | { readonly _tag: 'idle' } | { readonly _tag: 'uploading'; readonly anchor: UploadAnchor | null }; type UploadStateEffect = | { readonly _tag: 'start'; readonly anchor: UploadAnchor } | { readonly _tag: 'finish' }; type UploadStateEffectType = StateEffectType; const IDLE_UPLOAD_STATE: UploadState = { _tag: 'idle' }; const EMBED_PREFIX = '/embed '; const HTML_KIND: EmbedKind = 'html'; // Framed `` for an HTML document rendered in place. This follows the // editor package convention: 16x16 viewBox, currentColor, 1.5 stroke. const EMBED_ICON = ''; // A document with an upward arrow. `slashCommands()` owns the shared menu's // icon renderer; its completion metadata lets this picker use the same gutter. const UPLOAD_ICON = ''; /** * Build the static slash-menu item that hands completion to the `/embed` picker. * * @returns A slash command that rewrites the typed query to `/embed ` and * immediately reopens completion. */ export function embedSlashItem(): SlashCommandItem { return { label: 'Embed HTML', detail: 'live preview', icon: EMBED_ICON, apply: (view, completion, from, to) => { view.dispatch({ changes: { from, to, insert: EMBED_PREFIX }, selection: { anchor: from + EMBED_PREFIX.length }, annotations: pickedCompletion.of(completion), }); startCompletion(view); }, }; } /** * Build the `/embed` target picker extension for one editor mount. * * The editor captures extensions once per document mount. Keep the returned * extension stable and feed changing target data through the config callbacks. * * @param config - Host target, serialization, upload, and notice callbacks. * @returns A CodeMirror extension that composes with `slashCommands()`. */ export function embedPicker(config: EmbedPickerConfig): Extension { const uploadStateEffect = StateEffect.define(); const uploadStateField = StateField.define({ create: () => IDLE_UPLOAD_STATE, update: (current, transaction) => { let next = current; if (next._tag === 'uploading' && next.anchor !== null && transaction.docChanged) { const mappedFrom = transaction.changes.mapPos(next.anchor.from, 1, MapMode.TrackAfter); const mappedTo = transaction.changes.mapPos(next.anchor.to, -1, MapMode.TrackBefore); next = { _tag: 'uploading', anchor: mappedFrom === null || mappedTo === null || mappedFrom > mappedTo ? null : { ...next.anchor, from: mappedFrom, to: mappedTo }, }; } for (const effect of transaction.effects) { if (!effect.is(uploadStateEffect)) continue; next = effect.value._tag === 'start' ? { _tag: 'uploading', anchor: effect.value.anchor } : IDLE_UPLOAD_STATE; } return next; }, }); const source = createEmbedPickerSource(config, uploadStateField, uploadStateEffect); return [uploadStateField, EditorState.languageData.of(() => [{ autocomplete: source }])]; } function createEmbedPickerSource( config: EmbedPickerConfig, uploadStateField: StateField, uploadStateEffect: UploadStateEffectType, ): CompletionSource { return (context) => { const match = context.matchBefore(/\/embed +[^\n]*$/); if (match === null) return null; // Like the package slash source, the trigger must open its line. An embed // is a block insertion and belongs in its own paragraph. const line = context.state.doc.lineAt(match.from); if (!/^\s*$/.test(line.text.slice(0, match.from - line.from))) return null; const query = match.text.replace(/^\/embed +/, ''); const targets = config.getTargets().filter((target) => target.kind === HTML_KIND); const matches = filterTargets(targets, query); const options: IconCompletion[] = []; const notice = config.getNotice?.(context.state.doc.toString()) ?? null; if (notice !== null) { options.push({ label: notice, apply: () => { // Informational row: selection closes the menu and leaves the typed // command in place so the author can keep deciding. }, }); } if (matches.length === 0) { options.push({ label: targets.length === 0 ? config.labels?.empty ?? 'No HTML files in this workspace' : config.labels?.noMatch?.(query.trim()) ?? `No HTML files match “${query.trim()}”`, apply: (view, completion, _from, to) => { view.dispatch({ changes: { from: line.from, to, insert: '' }, annotations: pickedCompletion.of(completion), }); }, }); } else { for (const target of matches) { options.push({ label: targetLabel(target), detail: target.path, apply: (view, completion, from, to) => { applyTargetInsert(view, completion, from, to, target, config.buildInsertLine); }, }); } } const uploadTarget = config.uploadTarget; if (uploadTarget !== undefined) { const uploadState = context.state.field(uploadStateField); options.push( uploadState._tag === 'uploading' ? { label: 'Uploading...', slashCommandIcon: UPLOAD_ICON, apply: () => { // Inert while the existing host callback is still pending. }, } : { label: config.labels?.upload ?? 'Upload HTML...', slashCommandIcon: UPLOAD_ICON, apply: (view, completion, from, to) => { startUpload( view, completion, from, to, config, uploadTarget, uploadStateField, uploadStateEffect, ); }, }, ); } return { // The entire `/embed query` is the completion range. Our case-insensitive // substring matcher owns filtering because CM's fuzzy matcher closes a // completion session when a title query contains spaces. from: match.from, options, filter: false, }; }; } function filterTargets(targets: readonly EmbedTarget[], query: string): readonly EmbedTarget[] { const needle = query.trim().toLowerCase(); if (needle === '') return targets; return targets.filter((target) => { const title = (target.title ?? '').toLowerCase(); return title.includes(needle) || target.path.toLowerCase().includes(needle); }); } function targetLabel(target: EmbedTarget): string { const title = target.title ?? ''; return title.trim() === '' ? target.path : title; } function applyTargetInsert( view: EditorView, completion: Completion, from: number, to: number, target: EmbedTarget, buildInsertLine: EmbedPickerConfig['buildInsertLine'], ): void { const body = view.state.doc.toString(); const plan = planEmbedInsert(body, from, to, buildInsertLine(target)); view.dispatch({ changes: { from: plan.from, to: plan.to, insert: plan.insert }, selection: { anchor: plan.cursor }, scrollIntoView: true, annotations: pickedCompletion.of(completion), }); } function startUpload( view: EditorView, completion: Completion, from: number, to: number, config: EmbedPickerConfig, uploadTarget: NonNullable, uploadStateField: StateField, uploadStateEffect: UploadStateEffectType, ): void { const uploadState = view.state.field(uploadStateField, false); if (uploadState?._tag === 'uploading') return; const anchor: UploadAnchor = { from, to, text: view.state.doc.sliceString(from, to), }; view.dispatch({ effects: uploadStateEffect.of({ _tag: 'start', anchor }), annotations: pickedCompletion.of(completion), }); void (async () => { try { const target = await uploadTarget(HTML_KIND); finishUpload(view, completion, target, config, uploadStateField, uploadStateEffect); } catch { clearUpload(view, uploadStateField, uploadStateEffect); } })(); } function finishUpload( view: EditorView, completion: Completion, target: EmbedTarget | null, config: EmbedPickerConfig, uploadStateField: StateField, uploadStateEffect: UploadStateEffectType, ): void { const uploadState = view.state.field(uploadStateField, false); if (uploadState?._tag !== 'uploading') return; const anchor = uploadState.anchor; if (target === null || anchor === null || !isLiveAnchor(view, anchor)) { clearUpload(view, uploadStateField, uploadStateEffect); return; } const body = view.state.doc.toString(); const plan = planEmbedInsert(body, anchor.from, anchor.to, config.buildInsertLine(target)); view.dispatch({ changes: { from: plan.from, to: plan.to, insert: plan.insert }, selection: { anchor: plan.cursor }, scrollIntoView: true, effects: uploadStateEffect.of({ _tag: 'finish' }), annotations: pickedCompletion.of(completion), }); } function isLiveAnchor(view: EditorView, anchor: UploadAnchor): boolean { if (anchor.from > anchor.to || anchor.to > view.state.doc.length) return false; if (view.state.doc.sliceString(anchor.from, anchor.to) !== anchor.text) return false; const line = view.state.doc.lineAt(anchor.from); return /^\s*$/.test(line.text.slice(0, anchor.from - line.from)); } function clearUpload( view: EditorView, uploadStateField: StateField, uploadStateEffect: UploadStateEffectType, ): void { const uploadState = view.state.field(uploadStateField, false); if (uploadState?._tag !== 'uploading') return; view.dispatch({ effects: uploadStateEffect.of({ _tag: 'finish' }) }); }