silverbullet/web/cm_plugins/editor_paste.ts

248 lines
7.3 KiB
TypeScript
Raw Normal View History

import { syntaxTree } from "@codemirror/language";
import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
2023-07-14 22:56:20 +08:00
import { Client } from "../client.ts";
2024-05-28 02:33:41 +08:00
import { UploadFile } from "$sb/types.ts";
2022-04-12 02:34:09 +08:00
2022-11-09 18:38:12 +08:00
// We use turndown to convert HTML to Markdown
import TurndownService from "turndown";
2022-11-09 18:38:12 +08:00
// With tables and task notation as well
import { tables, taskListItems } from "turndown-plugin-gfm";
import { lezerToParseTree } from "$common/markdown_parser/parse_tree.ts";
2024-02-29 22:23:05 +08:00
import {
addParentPointers,
findParentMatching,
nodeAtPos,
2024-05-28 02:33:41 +08:00
} from "$sb/lib/tree.ts";
import { defaultLinkStyle, maximumAttachmentSize } from "../constants.ts";
import { safeRun } from "$lib/async.ts";
2024-05-28 02:33:41 +08:00
import { resolvePath } from "$sb/lib/resolve.ts";
2023-07-07 19:09:44 +08:00
2022-11-09 18:38:12 +08:00
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, "");
}
2022-04-12 02:34:09 +08:00
const urlRegexp =
/^https?:\/\/[-a-zA-Z0-9@:%._\+~#=]{1,256}([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/;
2022-03-30 21:16:22 +08:00
2022-05-16 21:09:36 +08:00
// Known iOS Safari paste issue (unrelated to this implementation): https://voxpelli.com/2015/03/ios-safari-url-copy-paste-bug/
2022-03-30 21:16:22 +08:00
export const pasteLinkExtension = ViewPlugin.fromClass(
class {
update(update: ViewUpdate): void {
update.transactions.forEach((tr) => {
if (tr.isUserEvent("input.paste")) {
2022-10-16 01:02:56 +08:00
const pastedText: string[] = [];
2022-03-30 21:16:22 +08:00
let from = 0;
let to = 0;
2022-10-16 01:02:56 +08:00
tr.changes.iterChanges((fromA, _toA, _fromB, toB, inserted) => {
2022-03-30 21:16:22 +08:00
pastedText.push(inserted.sliceString(0));
from = fromA;
to = toB;
});
2022-10-16 01:02:56 +08:00
const pastedString = pastedText.join("");
2022-03-30 21:16:22 +08:00
if (pastedString.match(urlRegexp)) {
2022-10-16 01:02:56 +08:00
const selection = update.startState.selection.main;
2022-03-30 21:16:22 +08:00
if (!selection.empty) {
setTimeout(() => {
update.view.dispatch({
changes: [
{
from: from,
to: to,
insert: `[${
update.startState.sliceDoc(
selection.from,
selection.to,
)
}](${pastedString})`,
2022-03-30 21:16:22 +08:00
},
],
});
});
}
}
}
});
}
},
2022-03-30 21:16:22 +08:00
);
2023-07-14 22:56:20 +08:00
export function attachmentExtension(editor: Client) {
let shiftDown = false;
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 attachment wherever the cursor was last.
if (event.dataTransfer) {
2022-10-16 01:02:56 +08:00
const payload = [...event.dataTransfer.files];
if (!payload.length) {
return;
}
safeRun(async () => {
await processFileTransfer(payload);
});
}
},
paste: (event: ClipboardEvent) => {
2022-10-16 01:02:56 +08:00
const payload = [...event.clipboardData!.items];
2022-11-09 18:38:12 +08:00
const richText = event.clipboardData?.getData("text/html");
2023-07-02 18:15:37 +08:00
// Only do rich text paste if shift is NOT down
if (richText && !shiftDown) {
2023-07-02 18:15:37 +08:00
// Are we in a fencede code block?
2023-07-27 17:41:44 +08:00
const editorText = editor.editorView.state.sliceDoc();
2023-07-02 18:15:37 +08:00
const tree = lezerToParseTree(
editorText,
2023-07-27 17:41:44 +08:00
syntaxTree(editor.editorView.state).topNode,
2023-07-02 18:15:37 +08:00
);
addParentPointers(tree);
const currentNode = nodeAtPos(
tree,
2023-07-27 17:41:44 +08:00
editor.editorView.state.selection.main.from,
2023-07-02 18:15:37 +08:00
);
if (currentNode) {
const fencedParentNode = findParentMatching(
currentNode,
(t) => ["FrontMatter", "FencedCode"].includes(t.type!),
2023-07-02 18:15:37 +08:00
);
if (
fencedParentNode ||
["FrontMatter", "FencedCode"].includes(currentNode.type!)
) {
2023-07-02 18:15:37 +08:00
console.log("Inside of fenced code block, not pasting rich text");
return false;
}
}
2022-11-09 18:38:12 +08:00
const markdown = striptHtmlComments(turndownService.turndown(richText))
.trim();
2023-07-27 17:41:44 +08:00
const view = editor.editorView;
2022-11-09 18:38:12 +08:00
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[]) {
2022-10-16 01:02:56 +08:00
const data = await payload[0].arrayBuffer();
// data.byteLength > maximumAttachmentSize;
2024-05-28 02:33:41 +08:00
const fileData: UploadFile = {
name: payload[0].name,
contentType: payload[0].type,
content: new Uint8Array(data),
};
await saveFile(fileData);
}
async function processItemTransfer(payload: DataTransferItem[]) {
2022-10-16 01:02:56 +08:00
const file = payload.find((item) => item.kind === "file");
if (!file) {
return false;
}
const fileType = file.type;
2022-10-16 01:02:56 +08:00
const ext = fileType.split("/")[1];
const fileName = new Date()
.toISOString()
.split(".")[0]
.replace("T", "_")
.replaceAll(":", "-");
2022-10-16 01:02:56 +08:00
const data = await file!.getAsFile()?.arrayBuffer();
2024-05-28 02:33:41 +08:00
if (!data) {
return false;
}
const fileData: UploadFile = {
name: `${fileName}.${ext}`,
contentType: fileType,
content: new Uint8Array(data),
};
await saveFile(fileData);
}
2024-05-28 02:33:41 +08:00
async function saveFile(file: UploadFile) {
const maxSize = editor.settings.maximumAttachmentSize ||
maximumAttachmentSize;
if (file.content.length > maxSize * 1024 * 1024) {
editor.flashNotification(
`Attachment is too large, maximum is ${maxSize}MiB`,
"error",
);
return;
}
2023-01-18 23:46:24 +08:00
const finalFileName = await editor.prompt(
"File name for pasted attachment",
2024-05-28 02:33:41 +08:00
file.name,
);
if (!finalFileName) {
return;
}
2024-05-28 02:33:41 +08:00
const attachmentPath = resolvePath(editor.currentPage, finalFileName);
await editor.space.writeAttachment(attachmentPath, file.content);
const linkStyle = editor.settings.defaultLinkStyle ||
defaultLinkStyle.toLowerCase();
let attachmentMarkdown = "";
if (linkStyle === "wikilink") {
attachmentMarkdown = `[[${attachmentPath}]]`;
} else {
attachmentMarkdown = `[${finalFileName}](${encodeURI(finalFileName)})`;
}
if (file.contentType.startsWith("image/")) {
attachmentMarkdown = "!" + attachmentMarkdown;
}
2023-07-27 17:41:44 +08:00
editor.editorView.dispatch({
changes: [
{
insert: attachmentMarkdown,
2023-07-27 17:41:44 +08:00
from: editor.editorView.state.selection.main.from,
},
],
});
}
}