memos/web/src/components/MemoEditor/Editor/extensions.ts

124 lines
4.8 KiB
TypeScript

import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { markdown } from "@codemirror/lang-markdown";
import { indentUnit } from "@codemirror/language";
import { Compartment, type Extension } from "@codemirror/state";
import { placeholder as cmPlaceholder, dropCursor, EditorView, type KeyBinding, keymap } from "@codemirror/view";
import { memoMarkdownExtensions } from "@/utils/memo-markdown-extension";
import { headingDecorations } from "./headingDecorations";
import { liftListItem, sinkListItem } from "./listIndent";
import { tagAutocomplete } from "./tagAutocomplete";
import { tagMentionDecorations } from "./tagMentionDecorations";
import { memoEditorTheme } from "./theme";
import { uploadAnchorField } from "./uploadAnchors";
// Key bindings layered below the autocomplete keymap so the completion popup's
// own Tab/Escape win while it is open. On a list item, Tab/Shift-Tab nest /
// outdent it (marker-aware, CommonMark-valid); elsewhere they fall through to
// indentWithTab's plain indent. Escape blurs the editor so keyboard users keep
// an escape hatch out of the otherwise Tab-trapping editor.
const editorKeys: KeyBinding[] = [
{
key: "Escape",
run: (view) => {
view.contentDOM.blur();
return true;
},
},
{ key: "Tab", run: sinkListItem },
{ key: "Shift-Tab", run: liftListItem },
];
export interface EditorExtensionsOptions {
placeholder: string;
onChange: (markdown: string) => void;
onFiles: (files: File[], position: number) => void;
onUpdate: () => void;
onSubmit: () => void;
getTags: () => string[];
}
export const placeholderCompartment = new Compartment();
function clipboardFiles(event: ClipboardEvent): File[] {
const clipboard = event.clipboardData;
if (!clipboard) return [];
const itemFiles = Array.from(clipboard.items)
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
return itemFiles.length > 0 ? itemFiles : Array.from(clipboard.files);
}
export function buildEditorExtensions({
placeholder,
onChange,
onFiles,
onUpdate,
onSubmit,
getTags,
}: EditorExtensionsOptions): Extension[] {
// Submitting must outrank defaultKeymap's own Mod-Enter (insertBlankLine): the save
// shortcut ends the memo, it must not also edit the document. Meta and Ctrl are bound
// explicitly (not via the platform-dependent Mod-) so Cmd+Enter and Ctrl+Enter both
// submit everywhere, matching the historical window-level shortcut.
const submit = () => {
onSubmit();
return true;
};
const submitKeys: KeyBinding[] = [
{ key: "Meta-Enter", run: submit },
{ key: "Ctrl-Enter", run: submit },
];
return [
// Core editing behavior. These are the pieces from CM6 setup that this memo
// editor uses, without enabling multi-cursor selection.
history(),
dropCursor(),
// Indent with spaces (markdown), matching the 2-space bullet nesting.
indentUnit.of(" "),
markdown({ extensions: memoMarkdownExtensions }),
...memoEditorTheme,
EditorView.lineWrapping,
// CodeMirror disables native text assistance because it is primarily a code
// editor. Memos is a prose editor, so restore the browser behavior used by
// the textarea editor before v0.30. Autocorrect also keeps Windows TSF input
// out of Chrome's autocorrect-suppression path, which has dropped committed
// text from the emoji picker.
EditorView.contentAttributes.of({
autocorrect: "on",
autocapitalize: "on",
spellcheck: "true",
}),
placeholderCompartment.of(cmPlaceholder(placeholder)),
EditorView.domEventHandlers({
paste: (event, view) => {
const files = clipboardFiles(event);
if (files.length === 0) return false;
onFiles(files, view.state.selection.main.head);
return true;
},
drop: (event, view) => {
const files = Array.from(event.dataTransfer?.files ?? []);
if (files.length === 0) return false;
const position = view.posAtCoords({ x: event.clientX, y: event.clientY }) ?? view.state.selection.main.head;
onFiles(files, position);
return true;
},
}),
tagMentionDecorations,
headingDecorations,
uploadAnchorField,
// tagAutocomplete must precede the editing keymap so the completion popup's
// Enter/Tab/arrow bindings win while it is open.
tagAutocomplete(getTags),
keymap.of([...submitKeys, ...editorKeys, indentWithTab, ...defaultKeymap, ...historyKeymap]),
EditorView.updateListener.of((u) => {
if (u.docChanged) onChange(u.state.doc.toString());
// Toolbar active-state depends only on the doc and selection; skip the
// getActiveFormats tree walk on focus/viewport/measure-only updates.
if (u.docChanged || u.selectionSet) onUpdate();
}),
];
}