Insert document title when pasting internal doc url (#6352)

* refactor

* DRY
This commit is contained in:
Tom Moor
2024-01-06 13:44:11 -08:00
committed by GitHub
parent 08b1755f8e
commit 92cbceb6c7
14 changed files with 123 additions and 51 deletions

View File

@@ -0,0 +1,38 @@
import { Plugin, PluginKey } from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension";
import textBetween from "@shared/editor/lib/textBetween";
/**
* A plugin that allows overriding the default behavior of the editor to allow
* copying text for nodes that do not inherently have text children by defining
* a `toPlainText` method in the node spec.
*/
export default class ClipboardTextSerializer extends Extension {
get name() {
return "clipboardTextSerializer";
}
get plugins() {
const textSerializers = Object.fromEntries(
Object.entries(this.editor.schema.nodes)
.filter(([, node]) => node.spec.toPlainText)
.map(([name, node]) => [name, node.spec.toPlainText])
);
return [
new Plugin({
key: new PluginKey("clipboardTextSerializer"),
props: {
clipboardTextSerializer: () => {
const { doc, selection } = this.editor.view.state;
const { ranges } = selection;
const from = Math.min(...ranges.map((range) => range.$from.pos));
const to = Math.max(...ranges.map((range) => range.$to.pos));
return textBetween(doc, from, to, textSerializers);
},
},
}),
];
}
}

View File

@@ -0,0 +1,100 @@
import { GapCursor } from "prosemirror-gapcursor";
import {
Plugin,
Selection,
AllSelection,
TextSelection,
EditorState,
Command,
} from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension";
import isInCode from "@shared/editor/queries/isInCode";
export default class Keys extends Extension {
get name() {
return "keys";
}
keys(): Record<string, Command> {
const onCancel = () => {
if (this.editor.props.onCancel) {
this.editor.props.onCancel();
return true;
}
return false;
};
return {
// Shortcuts for when editor has separate edit mode
"Mod-Escape": onCancel,
"Shift-Escape": onCancel,
"Mod-s": () => {
if (this.editor.props.onSave) {
this.editor.props.onSave({ done: false });
return true;
}
return false;
},
"Mod-Enter": (state: EditorState) => {
if (!isInCode(state) && this.editor.props.onSave) {
this.editor.props.onSave({ done: true });
return true;
}
return false;
},
Escape: () => {
(this.editor.view.dom as HTMLElement).blur();
return true;
},
};
}
get plugins() {
return [
new Plugin({
props: {
// we can't use the keys bindings for this as we want to preventDefault
// on the original keyboard event when handled
handleKeyDown: (view, event) => {
if (view.state.selection instanceof AllSelection) {
if (event.key === "ArrowUp") {
const selection = Selection.atStart(view.state.doc);
view.dispatch(view.state.tr.setSelection(selection));
return true;
}
if (event.key === "ArrowDown") {
const selection = Selection.atEnd(view.state.doc);
view.dispatch(view.state.tr.setSelection(selection));
return true;
}
}
// edge case where horizontal gap cursor does nothing if Enter key
// is pressed. Insert a newline and then move the cursor into it.
if (view.state.selection instanceof GapCursor) {
if (event.key === "Enter") {
view.dispatch(
view.state.tr.insert(
view.state.selection.from,
view.state.schema.nodes.paragraph.create({})
)
);
view.dispatch(
view.state.tr.setSelection(
TextSelection.near(
view.state.doc.resolve(view.state.selection.from),
-1
)
)
);
return true;
}
}
return false;
},
},
}),
];
}
}

View File

@@ -0,0 +1,52 @@
import {
ySyncPlugin,
yCursorPlugin,
yUndoPlugin,
undo,
redo,
} from "@getoutline/y-prosemirror";
import { keymap } from "prosemirror-keymap";
import * as Y from "yjs";
import Extension from "@shared/editor/lib/Extension";
export default class Multiplayer extends Extension {
get name() {
return "multiplayer";
}
get plugins() {
const { user, provider, document: doc } = this.options;
const type = doc.get("default", Y.XmlFragment);
const assignUser = (tr: Y.Transaction) => {
const clientIds = Array.from(doc.store.clients.keys());
if (
tr.local &&
tr.changed.size > 0 &&
!clientIds.includes(doc.clientID)
) {
const permanentUserData = new Y.PermanentUserData(doc);
permanentUserData.setUserMapping(doc, doc.clientID, user.id);
doc.off("afterTransaction", assignUser);
}
};
provider.setAwarenessField("user", user);
// only once an actual change has been made do we add the userId <> clientId
// mapping, this avoids stored mappings for clients that never made a change
doc.on("afterTransaction", assignUser);
return [
ySyncPlugin(type),
yCursorPlugin(provider.awareness),
yUndoPlugin(),
keymap({
"Mod-z": undo,
"Mod-y": redo,
"Mod-Shift-z": redo,
}),
];
}
}

View File

@@ -0,0 +1,305 @@
import { toggleMark } from "prosemirror-commands";
import { Slice } from "prosemirror-model";
import { Plugin } from "prosemirror-state";
import { LANGUAGES } from "@shared/editor/extensions/Prism";
import Extension from "@shared/editor/lib/Extension";
import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import isInCode from "@shared/editor/queries/isInCode";
import isInList from "@shared/editor/queries/isInList";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
/**
* Checks if the HTML string is likely coming from Dropbox Paper.
*
* @param html The HTML string to check.
* @returns True if the HTML string is likely coming from Dropbox Paper.
*/
function isDropboxPaper(html: string): boolean {
return html?.includes("usually-unique-id");
}
function sliceSingleNode(slice: Slice) {
return slice.openStart === 0 &&
slice.openEnd === 0 &&
slice.content.childCount === 1
? slice.content.firstChild
: null;
}
/**
* Parses the text contents of an HTML string and returns the src of the first
* iframe if it exists.
*
* @param text The HTML string to parse.
* @returns The src of the first iframe if it exists, or undefined.
*/
function parseSingleIframeSrc(html: string) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
if (
doc.body.children.length === 1 &&
doc.body.firstElementChild?.tagName === "IFRAME"
) {
const iframe = doc.body.firstElementChild;
const src = iframe.getAttribute("src");
if (src) {
return src;
}
}
} catch (e) {
// Ignore the million ways parsing could fail.
}
return undefined;
}
export default class PasteHandler extends Extension {
get name() {
return "paste-handler";
}
get plugins() {
return [
new Plugin({
props: {
transformPastedHTML(html: string) {
if (isDropboxPaper(html)) {
// Fixes double paragraphs when pasting from Dropbox Paper
html = html.replace(/<div><br><\/div>/gi, "<p></p>");
}
return html;
},
handleDOMEvents: {
keydown: (_, event) => {
if (event.key === "Shift") {
this.shiftKey = true;
}
return false;
},
keyup: (_, event) => {
if (event.key === "Shift") {
this.shiftKey = false;
}
return false;
},
},
handlePaste: (view, event: ClipboardEvent) => {
// Do nothing if the document isn't currently editable
if (view.props.editable && !view.props.editable(view.state)) {
return false;
}
// Default behavior if there is nothing on the clipboard or were
// special pasting with no formatting (Shift held)
if (!event.clipboardData || this.shiftKey) {
return false;
}
const { state, dispatch } = view;
const iframeSrc = parseSingleIframeSrc(
event.clipboardData.getData("text/plain")
);
const text =
iframeSrc && !isInCode(state)
? iframeSrc
: event.clipboardData.getData("text/plain");
const html = event.clipboardData.getData("text/html");
const vscode = event.clipboardData.getData("vscode-editor-data");
function insertLink(href: string, title?: string) {
const normalized = href.replace(/^https?:\/\//, "");
// If it's not an embed and there is no text selected just go ahead and insert the
// link directly
const transaction = view.state.tr
.insertText(
title ?? normalized,
state.selection.from,
state.selection.to
)
.addMark(
state.selection.from,
state.selection.to + (title ?? normalized).length,
state.schema.marks.link.create({ href })
);
view.dispatch(transaction);
}
// If the users selection is currently in a code block then paste
// as plain text, ignore all formatting and HTML content.
if (isInCode(state)) {
event.preventDefault();
view.dispatch(state.tr.insertText(text));
return true;
}
// Check if the clipboard contents can be parsed as a single url
if (isUrl(text)) {
// If there is selected text then we want to wrap it in a link to the url
if (!state.selection.empty) {
toggleMark(this.editor.schema.marks.link, { href: text })(
state,
dispatch
);
return true;
}
// Is this link embeddable? Create an embed!
const { embeds } = this.editor.props;
if (
embeds &&
this.editor.commands.embed &&
!isInCode(state) &&
!isInList(state)
) {
for (const embed of embeds) {
const matches = embed.matcher(text);
if (matches) {
this.editor.commands.embed({
href: text,
});
return true;
}
}
}
// Is the link a link to a document? If so, we can grab the title and insert it.
if (isDocumentUrl(text)) {
const slug = parseDocumentSlug(text);
if (slug) {
void stores.documents
.fetch(slug)
.then((document) => {
if (view.isDestroyed) {
return;
}
if (document) {
const title = `${
document.emoji ? document.emoji + " " : ""
}${document.titleWithDefault}`;
insertLink(document.path, title);
}
})
.catch(() => {
if (view.isDestroyed) {
return;
}
insertLink(text);
});
}
} else {
insertLink(text);
}
return true;
}
// Because VSCode is an especially popular editor that places metadata
// on the clipboard, we can parse it to find out what kind of content
// was pasted.
const vscodeMeta = vscode ? JSON.parse(vscode) : undefined;
const pasteCodeLanguage = vscodeMeta?.mode;
const supportsCodeBlock = !!state.schema.nodes.code_block;
const supportsCodeMark = !!state.schema.marks.code_inline;
if (pasteCodeLanguage && pasteCodeLanguage !== "markdown") {
if (text.includes("\n") && supportsCodeBlock) {
event.preventDefault();
view.dispatch(
state.tr
.replaceSelectionWith(
state.schema.nodes.code_block.create({
language: Object.keys(LANGUAGES).includes(
vscodeMeta.mode
)
? vscodeMeta.mode
: null,
})
)
.insertText(text)
);
return true;
}
if (supportsCodeMark) {
event.preventDefault();
view.dispatch(
state.tr
.insertText(text, state.selection.from, state.selection.to)
.addMark(
state.selection.from,
state.selection.to + text.length,
state.schema.marks.code_inline.create()
)
);
return true;
}
}
// If the HTML on the clipboard is from Prosemirror then the best
// compatability is to just use the HTML parser, regardless of
// whether it "looks" like Markdown, see: outline/outline#2416
if (html?.includes("data-pm-slice")) {
return false;
}
// If the text on the clipboard looks like Markdown OR there is no
// html on the clipboard then try to parse content as Markdown
if (
(isMarkdown(text) && !isDropboxPaper(html)) ||
pasteCodeLanguage === "markdown"
) {
event.preventDefault();
// get pasted content as slice
const paste = this.editor.pasteParser.parse(
normalizePastedMarkdown(text)
);
if (!paste) {
return false;
}
const slice = paste.slice(0);
const tr = view.state.tr;
let currentPos = view.state.selection.from;
// If the pasted content is a single paragraph then we loop over
// it's content and insert each node one at a time to allow it to
// be pasted inline with surrounding content.
const singleNode = sliceSingleNode(slice);
if (singleNode?.type === this.editor.schema.nodes.paragraph) {
singleNode.forEach((node) => {
tr.insert(currentPos, node);
currentPos += node.nodeSize;
});
} else {
singleNode
? tr.replaceSelectionWith(singleNode, this.shiftKey)
: tr.replaceSelection(slice);
}
view.dispatch(
tr
.scrollIntoView()
.setMeta("paste", true)
.setMeta("uiEvent", "paste")
);
return true;
}
// otherwise use the default HTML parser which will handle all paste
// "from the web" events
return false;
},
},
}),
];
}
/** Tracks whether the Shift key is currently held down */
private shiftKey = false;
}

View File

@@ -0,0 +1,16 @@
import { Command } from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension";
export default class PreventTab extends Extension {
get name() {
return "preventTab";
}
keys(): Record<string, Command> {
return {
// No-ops prevent Tab escaping the editor bounds
Tab: () => true,
"Shift-Tab": () => true,
};
}
}

View File

@@ -0,0 +1,28 @@
import { ellipsis, smartQuotes, InputRule } from "prosemirror-inputrules";
import Extension from "@shared/editor/lib/Extension";
const rightArrow = new InputRule(/->$/, "→");
const oneHalf = new InputRule(/1\/2$/, "½");
const threeQuarters = new InputRule(/3\/4$/, "¾");
const copyright = new InputRule(/\(c\)$/, "©️");
const registered = new InputRule(/\(r\)$/, "®️");
const trademarked = new InputRule(/\(tm\)$/, "™️");
export default class SmartText extends Extension {
get name() {
return "smart_text";
}
inputRules() {
return [
rightArrow,
oneHalf,
threeQuarters,
copyright,
registered,
trademarked,
ellipsis,
...smartQuotes,
];
}
}