124 lines
4.8 KiB
TypeScript
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();
|
|
}),
|
|
];
|
|
}
|