Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(commonmark-editor): detect and format pasted code #147

Merged
merged 18 commits into from Jun 29, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/commonmark/editor.ts
Expand Up @@ -6,6 +6,7 @@ import { IExternalPluginProvider } from "../shared/editor-plugin";
import { CodeBlockHighlightPlugin } from "../shared/highlighting/highlight-plugin";
import { log } from "../shared/logger";
import { createMenuPlugin } from "../shared/menu";
import { commonmarkCodePasteHandler } from "../shared/prosemirror-plugins/code-paste-handler";
import {
commonmarkImageUpload,
defaultImageUploadHandler,
Expand Down Expand Up @@ -76,6 +77,7 @@ export class CommonmarkEditor extends BaseView {
),
placeholderPlugin(this.options.placeholderText),
readonlyPlugin(),
commonmarkCodePasteHandler,
...pluginProvider.plugins.commonmark,
],
}),
Expand Down
4 changes: 2 additions & 2 deletions src/rich-text/editor.ts
Expand Up @@ -30,7 +30,7 @@ import { CodeBlockView } from "./node-views/code-block";
import { HtmlBlock, HtmlBlockContainer } from "./node-views/html-block";
import { ImageView } from "./node-views/image";
import { TagLink } from "./node-views/tag-link";
import { codePasteHandler } from "./plugins/code-paste-handler";
import { richTextCodePasteHandler } from "../shared/prosemirror-plugins/code-paste-handler";
import { linkPasteHandler } from "./plugins/link-paste-handler";
import { linkPreviewPlugin, LinkPreviewProvider } from "./plugins/link-preview";
import { linkEditorPlugin } from "./plugins/link-editor";
Expand Down Expand Up @@ -135,7 +135,7 @@ export class RichTextEditor extends BaseView {
readonlyPlugin(),
spoilerToggle,
tables,
codePasteHandler,
richTextCodePasteHandler,
linkPasteHandler(this.options.parserFeatures),
...this.externalPluginProvider.plugins.richText,
// IMPORTANT: the plainTextPasteHandler must be added after *all* other paste handlers
Expand Down
90 changes: 0 additions & 90 deletions src/rich-text/plugins/code-paste-handler.ts

This file was deleted.

160 changes: 160 additions & 0 deletions src/shared/prosemirror-plugins/code-paste-handler.ts
@@ -0,0 +1,160 @@
import { Plugin } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { Slice, Node, DOMParser, Schema } from "prosemirror-model";
import { richTextSchemaSpec } from "../../rich-text/schema";

// create a static, mini schema for detecting code blocks in clipboard content
const miniSchema = new Schema({
nodes: {
doc: richTextSchemaSpec.nodes.doc,
text: richTextSchemaSpec.nodes.text,
code_block: richTextSchemaSpec.nodes.code_block,
},
});

function getHtmlClipboardContent(clipboardData: DataTransfer) {
if (!clipboardData.types.includes("text/html")) {
return null;
}

return new global.DOMParser().parseFromString(
clipboardData.getData("text/html"),
"text/html"
);
}

/**
* Detects if code was pasted into the document and returns the text if true
* @param clipboardData The clipboardData from the ClipboardEvent
*/
function getDetectedCode(
clipboardData: DataTransfer,
htmlDoc: Document
): string | null {
// if we're loading a whole document, don't false positive if there's more than just code
const codeEl = htmlDoc?.querySelector("code");
if (htmlDoc && codeEl) {
return htmlDoc.body.textContent.trim() !== codeEl.textContent
? null
: codeEl.textContent;
}

const textContent = clipboardData.getData("text/plain");

if (!textContent) {
return null;
}

// TODO how to reliably detect if a string is code?

// TODO add more support?
// check if there's ide specific paste data present
if (clipboardData.getData("vscode-editor-data")) {
// TODO parse data for language?
return textContent;
}

// no ide detected, try detecting leading indentation
// true if any line starts with: 2+ space characters, 1 tab character
if (/^([ ]{2,}|\t)/m.test(textContent)) {
return textContent;
}

return null;
}

/**
* Parses a code string from pasted text, based on multiple heuristics
* @param clipboardData The ClipboardEvent.clipboardData from the clipboard paste event
* @param doc Pre-parsed slice, if already available; otherwise the slice will be parsed from the clipboard's html data
* @internal
*/
export function parseCodeFromPasteData(
clipboardData: DataTransfer,
doc?: Slice | Node
) {
let codeData: string;

let htmlContent: Document | null = null;
if (!doc) {
htmlContent = getHtmlClipboardContent(clipboardData);

if (htmlContent) {
doc = DOMParser.fromSchema(miniSchema).parse(htmlContent);
}
}

// if the schema parser already detected a code block, just use that
if (
doc &&
doc.content.childCount === 1 &&
doc.content.child(0).type.name === "code_block"
) {
codeData = doc.content.child(0).textContent;
} else {
// if not parsed above, parse here - this allows us to only run the parse when it is necessary
htmlContent ??= getHtmlClipboardContent(clipboardData);
codeData = getDetectedCode(clipboardData, htmlContent);
}

if (!codeData) {
return null;
}

// TODO can we do some basic formatting?

return codeData;
}

/** Plugin for the rich-text editor that auto-detects if code was pasted and handles it specifically */
export const richTextCodePasteHandler = new Plugin({
props: {
handlePaste(view: EditorView, event: ClipboardEvent, slice: Slice) {
// if we're pasting into an existing code block, don't bother checking for code
const schema = view.state.schema;
const codeblockType = schema.nodes.code_block;
const currNodeType = view.state.selection.$from.node().type;
if (currNodeType === codeblockType) {
return false;
}

const codeData = parseCodeFromPasteData(event.clipboardData, slice);

if (!codeData) {
return false;
}

const node = codeblockType.createChecked({}, schema.text(codeData));
view.dispatch(view.state.tr.replaceSelectionWith(node));

return true;
},
},
});

/** Plugin for the commonmark editor that auto-detects if code was pasted and handles it specifically */
export const commonmarkCodePasteHandler = new Plugin({
props: {
handlePaste(view: EditorView, event: ClipboardEvent) {
// unlike the rich-text schema, the commonmark schema doesn't allow code_blocks in the root node
// so pass in a null slice so the code manually parses instead
let codeData = parseCodeFromPasteData(event.clipboardData, null);

if (!codeData) {
return false;
}

const { $from } = view.state.selection;

// wrap the code in a markdown code fence
codeData = "```\n" + codeData + "\n```\n";

// add a newline if we're not at the beginning of the document
codeData = ($from.pos === 1 ? "" : "\n") + codeData;

view.dispatch(view.state.tr.insertText(codeData));

return true;
},
},
});
6 changes: 5 additions & 1 deletion test/rich-text/test-helpers.ts
Expand Up @@ -132,7 +132,11 @@ export class DataTransferMock implements DataTransfer {
effectAllowed: DataTransfer["effectAllowed"];
files: FileList;
items: DataTransferItemList;
types: readonly string[];

get types() {
return Object.keys(this.data);
}

clearData(): void {
throw new Error("Method not implemented.");
}
Expand Down