mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
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<EmbedTarget | null>;
|
|
/** Return an optional informational row for the current document text. */
|
|
readonly getNotice?: (docBody: string) => string | null;
|
|
}
|
|
|
|
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<UploadStateEffect>;
|
|
|
|
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 =
|
|
'<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M6.4 6 4.4 8l2 2"/><path d="M9.6 6 11.6 8l-2 2"/></svg>';
|
|
|
|
// 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 =
|
|
'<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8.5V13h10V8.5"/><path d="M8 11V3M5 6l3-3 3 3"/></svg>';
|
|
|
|
/**
|
|
* 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<UploadStateEffect>();
|
|
const uploadStateField = StateField.define<UploadState>({
|
|
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<UploadState>,
|
|
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
|
|
? 'No HTML files in this workspace'
|
|
: `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: '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<EmbedPickerConfig['uploadTarget']>,
|
|
uploadStateField: StateField<UploadState>,
|
|
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<UploadState>,
|
|
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<UploadState>,
|
|
uploadStateEffect: UploadStateEffectType,
|
|
): void {
|
|
const uploadState = view.state.field(uploadStateField, false);
|
|
if (uploadState?._tag !== 'uploading') return;
|
|
view.dispatch({ effects: uploadStateEffect.of({ _tag: 'finish' }) });
|
|
}
|