Insert document title when pasting internal doc url (#6352)
* refactor * DRY
This commit is contained in:
@@ -1,38 +0,0 @@
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
import Extension from "../lib/Extension";
|
||||
import textBetween from "../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);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { GapCursor } from "prosemirror-gapcursor";
|
||||
import {
|
||||
Plugin,
|
||||
Selection,
|
||||
AllSelection,
|
||||
TextSelection,
|
||||
EditorState,
|
||||
Command,
|
||||
} from "prosemirror-state";
|
||||
import Extension from "../lib/Extension";
|
||||
import isInCode from "../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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import {
|
||||
ySyncPlugin,
|
||||
yCursorPlugin,
|
||||
yUndoPlugin,
|
||||
undo,
|
||||
redo,
|
||||
} from "@getoutline/y-prosemirror";
|
||||
import { keymap } from "prosemirror-keymap";
|
||||
import * as Y from "yjs";
|
||||
import Extension from "../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,
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
import { toggleMark } from "prosemirror-commands";
|
||||
import { Slice } from "prosemirror-model";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import { isUrl } from "../../utils/urls";
|
||||
import Extension from "../lib/Extension";
|
||||
import isMarkdown from "../lib/isMarkdown";
|
||||
import normalizePastedMarkdown from "../lib/markdown/normalize";
|
||||
import isInCode from "../queries/isInCode";
|
||||
import isInList from "../queries/isInList";
|
||||
import { LANGUAGES } from "./Prism";
|
||||
|
||||
/**
|
||||
* 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");
|
||||
|
||||
// first check if the clipboard contents can be parsed as a single
|
||||
// url, this is mainly for allowing pasted urls to become embeds
|
||||
if (isUrl(text)) {
|
||||
// just paste the link mark directly onto the selected text
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// well, it's not an embed and there is no text selected – so just
|
||||
// go ahead and insert the link directly
|
||||
const transaction = view.state.tr
|
||||
.insertText(text, state.selection.from, state.selection.to)
|
||||
.addMark(
|
||||
state.selection.from,
|
||||
state.selection.to + text.length,
|
||||
state.schema.marks.link.create({ href: text })
|
||||
);
|
||||
view.dispatch(transaction);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Command } from "prosemirror-state";
|
||||
import Extension from "../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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { ellipsis, smartQuotes, InputRule } from "prosemirror-inputrules";
|
||||
import Extension from "../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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { InputRule } from "prosemirror-inputrules";
|
||||
import { NodeType, MarkType, Schema } from "prosemirror-model";
|
||||
import { Command, Plugin } from "prosemirror-state";
|
||||
import { Primitive } from "utility-types";
|
||||
import { Editor } from "../../../app/editor";
|
||||
import type { Editor } from "../../../app/editor";
|
||||
|
||||
export type CommandFactory = (attrs?: Record<string, Primitive>) => Command;
|
||||
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import ClipboardTextSerializer from "../extensions/ClipboardTextSerializer";
|
||||
import DateTime from "../extensions/DateTime";
|
||||
import History from "../extensions/History";
|
||||
import Keys from "../extensions/Keys";
|
||||
import MaxLength from "../extensions/MaxLength";
|
||||
import PasteHandler from "../extensions/PasteHandler";
|
||||
import Placeholder from "../extensions/Placeholder";
|
||||
import PreventTab from "../extensions/PreventTab";
|
||||
import SmartText from "../extensions/SmartText";
|
||||
import TrailingNode from "../extensions/TrailingNode";
|
||||
import Extension from "../lib/Extension";
|
||||
import Bold from "../marks/Bold";
|
||||
@@ -68,14 +63,10 @@ export const basicExtensions: Nodes = [
|
||||
Link,
|
||||
Strikethrough,
|
||||
History,
|
||||
SmartText,
|
||||
TrailingNode,
|
||||
PasteHandler,
|
||||
Placeholder,
|
||||
MaxLength,
|
||||
DateTime,
|
||||
Keys,
|
||||
ClipboardTextSerializer,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -83,7 +74,7 @@ export const basicExtensions: Nodes = [
|
||||
* editors that need advanced formatting.
|
||||
*/
|
||||
export const richExtensions: Nodes = [
|
||||
...basicExtensions.filter((n) => n !== SimpleImage && n !== Keys),
|
||||
...basicExtensions.filter((n) => n !== SimpleImage),
|
||||
Image,
|
||||
HardBreak,
|
||||
CodeBlock,
|
||||
@@ -108,8 +99,6 @@ export const richExtensions: Nodes = [
|
||||
TemplatePlaceholder,
|
||||
Math,
|
||||
MathBlock,
|
||||
PreventTab,
|
||||
Keys,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,10 +38,28 @@ export function isInternalUrl(href: string) {
|
||||
return (
|
||||
outline.host === domain.host ||
|
||||
(domain.host.endsWith(getBaseDomain()) &&
|
||||
!RESERVED_SUBDOMAINS.includes(domain.teamSubdomain))
|
||||
!RESERVED_SUBDOMAINS.find((reserved) => domain.host.startsWith(reserved)))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given string is a link to a documement.
|
||||
*
|
||||
* @param options Parsing options.
|
||||
* @returns True if a document, false otherwise.
|
||||
*/
|
||||
export function isDocumentUrl(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return (
|
||||
isInternalUrl(url) &&
|
||||
(parsed.pathname.startsWith("/doc/") || parsed.pathname.startsWith("/d/"))
|
||||
);
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given string is a url.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user