Files
plainleaf/client/codemirror/editor_paste.ts
T

363 lines
12 KiB
TypeScript

import { syntaxTree } from "@codemirror/language";
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { EditorSelection, Transaction } from "@codemirror/state";
import type { Client } from "../client.ts";
import { lezerToParseTree } from "../markdown_parser/parse_tree.ts";
import {
addParentPointers,
findParentMatching,
nodeAtPos,
} from "@silverbulletmd/silverbullet/lib/tree";
import { maximumDocumentSize } from "@silverbulletmd/silverbullet/constants";
import { safeRun } from "@silverbulletmd/silverbullet/lib/async";
import { resolveMarkdownLink } from "@silverbulletmd/silverbullet/lib/resolve";
import { localDateString } from "@silverbulletmd/silverbullet/lib/dates";
import type { UploadFile } from "@silverbulletmd/silverbullet/type/client";
import { isValidName, isValidPath } from "@silverbulletmd/silverbullet/lib/ref";
import TurndownService from "turndown";
// @ts-expect-error - No type definitions available for this package
import { tables, taskListItems } from "@joplin/turndown-plugin-gfm";
const turndownService = new TurndownService({
hr: "---",
codeBlockStyle: "fenced",
headingStyle: "atx",
emDelimiter: "*",
bulletListMarker: "*", // Duh!
strongDelimiter: "**",
linkStyle: "inlined",
});
turndownService.use(taskListItems);
turndownService.use(tables);
function striptHtmlComments(s: string): string {
return s.replace(/<!--[\s\S]*?-->/g, "");
}
function ensureValidFilenameWithExtension(filename: string): string {
if (isValidPath(filename)) {
return filename;
}
const match = filename.match(/\.([^.]+)$/);
return `file.${match ? match[1] : "txt"}`;
}
async function doesFileExist(
editor: Client,
filePath: string,
): Promise<boolean> {
try {
await editor.space.spacePrimitives.getFileMeta(filePath);
return true;
} catch {
return false;
}
}
const urlRegexp =
/^https?:\/\/[-a-zA-Z0-9@:%._+~#=]{1,256}([-a-zA-Z0-9()@:%_+.~#?&//=]*)/;
// Safari/WebKit only: after a paste that triggers a decoration-driven DOM
// rebuild (e.g. pasting a URL inside `[text]()` to complete a markdown link),
// WebKit leaves the contentEditable typing caret at the *pre-paste* position.
// `document.getSelection()` reports the correct post-paste position, so
// CodeMirror thinks the DOM selection is already in sync and skips re-writing
// it — and the next keystroke gets inserted at the stale caret. Forcing
// CodeMirror to actually perform a DOM selection write makes WebKit re-resolve
// its caret. We do that by briefly nudging the selection and restoring it once
// the paste (and its decoration rebuild) has settled.
const isWebKit =
typeof navigator !== "undefined" && /Apple Computer/.test(navigator.vendor);
function fixupWebKitCaretAfterPaste(view: EditorView): void {
if (!isWebKit) return;
// Run after the synchronous paste handling + decoration rebuild have
// settled, but before the user's next keystroke.
queueMicrotask(() => {
const sel = view.state.selection;
const head = sel.main.head;
const docLen = view.state.doc.length;
// Pick a different position to force a real DOM selection write.
const bump = head > 0 ? head - 1 : docLen > 0 ? head + 1 : head;
if (bump === head) return; // empty document, nothing to re-resolve
const noHistory = Transaction.addToHistory.of(false);
view.dispatch({
selection: EditorSelection.cursor(bump),
annotations: noHistory,
});
// Restore the exact original selection (preserves multi-cursor ranges).
view.dispatch({ selection: sel, annotations: noHistory });
});
}
// Known iOS Safari paste issue (unrelated to this implementation): https://voxpelli.com/2015/03/ios-safari-url-copy-paste-bug/
export const pasteLinkExtension = ViewPlugin.fromClass(
class {
update(update: ViewUpdate): void {
update.transactions.forEach((tr) => {
if (tr.isUserEvent("input.paste")) {
const pastedText: string[] = [];
let from = 0;
let to = 0;
tr.changes.iterChanges((fromA, _toA, _fromB, toB, inserted) => {
pastedText.push(inserted.sliceString(0));
from = fromA;
to = toB;
});
const pastedString = pastedText.join("");
if (pastedString.match(urlRegexp)) {
const selection = update.startState.selection.main;
if (!selection.empty) {
setTimeout(() => {
update.view.dispatch({
changes: [
{
from: from,
to: to,
insert: `[${update.startState.sliceDoc(
selection.from,
selection.to,
)}](${pastedString})`,
},
],
});
});
}
}
}
});
}
},
);
export function documentExtension(editor: Client) {
let shiftDown = false;
// Public embedder API: dispatch a `silverbullet:upload-files` CustomEvent
// on `document` with `{ files: File[] }` in `detail` to hand the editor
// a list of files. A bit hacky but required for correct Tauri operation.
document.addEventListener("silverbullet:upload-files", (event) => {
const files = (event as CustomEvent<{ files?: File[] }>).detail?.files;
if (!files?.length) return;
safeRun(async () => {
await processFileTransfer(files);
});
});
return EditorView.domEventHandlers({
dragover: (event) => {
event.preventDefault();
},
keydown: (event) => {
if (event.key === "Shift") {
shiftDown = true;
}
return false;
},
keyup: (event) => {
if (event.key === "Shift") {
shiftDown = false;
}
return false;
},
drop: (event: DragEvent) => {
// TODO: This doesn't take into account the target cursor position,
// it just drops the document wherever the cursor was last.
if (event.dataTransfer) {
const payload = [...event.dataTransfer.files];
if (!payload.length) {
return;
}
// Without this the browser falls through to its default action
// (navigating to the dropped file)
event.preventDefault();
safeRun(async () => {
await processFileTransfer(payload);
});
}
},
paste: (event: ClipboardEvent) => {
// Schedule a WebKit caret re-sync regardless of which paste path runs
// below (or CodeMirror's own default paste handling, which fires after
// this handler returns a falsy value).
fixupWebKitCaretAfterPaste(editor.editorView);
const payload = [...event.clipboardData!.items];
const richText = event.clipboardData?.getData("text/html");
// Only do rich text paste if shift is NOT down
if (richText && !shiftDown) {
// Are we in a fenced code block?
const editorText = editor.editorView.state.sliceDoc();
const tree = lezerToParseTree(
editorText,
syntaxTree(editor.editorView.state).topNode,
);
addParentPointers(tree);
const currentNode = nodeAtPos(
tree,
editor.editorView.state.selection.main.from,
);
if (currentNode) {
const fencedParentNode = findParentMatching(currentNode, (t) =>
["FrontMatter", "FencedCode"].includes(t.type!),
);
if (
fencedParentNode ||
["FrontMatter", "FencedCode"].includes(currentNode.type!)
) {
console.log("Inside of fenced code block, not pasting rich text");
return false;
}
}
event.preventDefault();
const markdown = striptHtmlComments(
turndownService.turndown(richText),
).trim();
const view = editor.editorView;
const selection = view.state.selection.main;
view.dispatch({
changes: [
{
from: selection.from,
to: selection.to,
insert: markdown,
},
],
selection: {
anchor: selection.from + markdown.length,
},
scrollIntoView: true,
});
return true;
}
if (!payload.length || payload.length === 0) {
return false;
}
safeRun(async () => {
await processItemTransfer(payload);
});
},
});
async function processFileTransfer(payload: File[]) {
const data = await payload[0].arrayBuffer();
// data.byteLength > maximumDocumentSize;
const fileData: UploadFile = {
name: payload[0].name,
contentType: payload[0].type,
content: new Uint8Array(data),
};
await saveFile(fileData);
}
async function processItemTransfer(payload: DataTransferItem[]) {
const file = payload.find((item) => item.kind === "file");
if (!file) {
return false;
}
const fileType = file.type;
const ext = fileType.split("/")[1];
const fileName = localDateString(new Date())
.split(".")[0]
.replace("T", "_")
.replaceAll(":", "-");
const data = await file!.getAsFile()?.arrayBuffer();
if (!data) {
return false;
}
const fileData: UploadFile = {
name: `${fileName}.${ext}`,
contentType: fileType,
content: new Uint8Array(data),
};
await saveFile(fileData);
}
async function saveFile(file: UploadFile) {
const maxSize = maximumDocumentSize;
const invalidPathMessage =
"Unable to upload file, invalid target filename or path";
if (file.content.length > maxSize * 1024 * 1024) {
editor.ui.flashNotification(
`Document is too large, maximum is ${maxSize}MiB`,
"error",
);
return;
}
let desiredFilePath = await editor.ui.prompt(
"File name for pasted document",
resolveMarkdownLink(
client.currentPath(),
ensureValidFilenameWithExtension(file.name),
),
);
if (desiredFilePath === undefined) {
// User hit cancel, so they know why we stopped and dont need an notification.
return;
}
desiredFilePath = desiredFilePath.trim();
if (!isValidName(desiredFilePath)) {
editor.ui.flashNotification(invalidPathMessage, "error");
return;
}
// Check the given desired file path wont clobber an existing file. If it
// would, ask the user to confirm or provide another filename. Repeat this
// check for every new filename they give.
// Note: duplicate any modifications here to client/code_mirror/editor_paste.ts
let finalFilePath = null;
while (finalFilePath == null) {
if (await doesFileExist(editor, desiredFilePath)) {
let confirmedFilePath = await editor.ui.prompt(
"A file with that name already exists, keep the same name to replace it, or rename your file",
resolveMarkdownLink(
client.currentPath(),
ensureValidFilenameWithExtension(desiredFilePath),
),
);
if (confirmedFilePath === undefined) {
// Unlike the initial filename prompt, we're inside a workflow here
// and should be explicit that the user action cancelled the whole
// operation.
editor.ui.flashNotification("Upload cancelled by user", "info");
return;
}
confirmedFilePath = confirmedFilePath.trim();
if (!isValidPath(confirmedFilePath)) {
editor.ui.flashNotification(invalidPathMessage, "error");
return;
}
if (desiredFilePath === confirmedFilePath) {
// if we got back the same path, we're replacing and should accept the given name
finalFilePath = desiredFilePath;
} else {
// we got a new path, so we must repeat the check
desiredFilePath = confirmedFilePath;
confirmedFilePath = undefined;
}
} else {
finalFilePath = desiredFilePath;
}
}
await editor.space.writeDocument(finalFilePath, file.content);
let documentMarkdown = `[[${finalFilePath}]]`;
if (file.contentType.startsWith("image/")) {
documentMarkdown = `!${documentMarkdown}`;
}
editor.editorView.dispatch({
changes: [
{
insert: documentMarkdown,
from: editor.editorView.state.selection.main.from,
},
],
});
}
}