Skip to content

Commit

Permalink
feat(commonmark-editor): detect and format pasted code like rich-text…
Browse files Browse the repository at this point in the history
… mode (#147)

fixes #135
  • Loading branch information
dancormier committed Jun 29, 2022
1 parent c0fe99f commit 9d9841a
Show file tree
Hide file tree
Showing 6 changed files with 297 additions and 113 deletions.
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

0 comments on commit 9d9841a

Please sign in to comment.