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

Mobile,Desktop: CodeMirror 6 editor: Convert selected text to link when pasting URLs #9344

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,8 @@ packages/editor/CodeMirror/getScrollFraction.js
packages/editor/CodeMirror/markdown/computeSelectionFormatting.test.js
packages/editor/CodeMirror/markdown/computeSelectionFormatting.js
packages/editor/CodeMirror/markdown/decoratorExtension.js
packages/editor/CodeMirror/markdown/fastLinksExtension.test.js
packages/editor/CodeMirror/markdown/fastLinksExtension.js
packages/editor/CodeMirror/markdown/markdownCommands.bulletedVsChecklist.test.js
packages/editor/CodeMirror/markdown/markdownCommands.test.js
packages/editor/CodeMirror/markdown/markdownCommands.toggleList.test.js
Expand All @@ -559,7 +561,7 @@ packages/editor/CodeMirror/testUtil/createTestEditor.js
packages/editor/CodeMirror/testUtil/forceFullParse.js
packages/editor/CodeMirror/testUtil/loadLanguages.js
packages/editor/CodeMirror/theme.js
packages/editor/CodeMirror/util/isInSyntaxNode.js
packages/editor/CodeMirror/util/intersectsAnySyntaxNodeOf.js
packages/editor/SelectionFormatting.js
packages/editor/events.js
packages/editor/types.js
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,8 @@ packages/editor/CodeMirror/getScrollFraction.js
packages/editor/CodeMirror/markdown/computeSelectionFormatting.test.js
packages/editor/CodeMirror/markdown/computeSelectionFormatting.js
packages/editor/CodeMirror/markdown/decoratorExtension.js
packages/editor/CodeMirror/markdown/fastLinksExtension.test.js
packages/editor/CodeMirror/markdown/fastLinksExtension.js
packages/editor/CodeMirror/markdown/markdownCommands.bulletedVsChecklist.test.js
packages/editor/CodeMirror/markdown/markdownCommands.test.js
packages/editor/CodeMirror/markdown/markdownCommands.toggleList.test.js
Expand All @@ -541,7 +543,7 @@ packages/editor/CodeMirror/testUtil/createTestEditor.js
packages/editor/CodeMirror/testUtil/forceFullParse.js
packages/editor/CodeMirror/testUtil/loadLanguages.js
packages/editor/CodeMirror/theme.js
packages/editor/CodeMirror/util/isInSyntaxNode.js
packages/editor/CodeMirror/util/intersectsAnySyntaxNodeOf.js
packages/editor/SelectionFormatting.js
packages/editor/events.js
packages/editor/types.js
Expand Down
3 changes: 3 additions & 0 deletions packages/editor/CodeMirror/configFromSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { html } from '@codemirror/lang-html';
import { defaultKeymap, emacsStyleKeymap } from '@codemirror/commands';
import { vim } from '@replit/codemirror-vim';
import { indentUnit } from '@codemirror/language';
import fastLinksExtension from './markdown/fastLinksExtension';

const configFromSettings = (settings: EditorSettings) => {
const languageExtension = (() => {
Expand All @@ -29,6 +30,8 @@ const configFromSettings = (settings: EditorSettings) => {
codeLanguages: syntaxHighlightingLanguages,
}),
markdownLanguage.data.of({ closeBrackets: openingBrackets }),

fastLinksExtension(),
];
} else if (language === EditorLanguageType.Html) {
return html();
Expand Down
59 changes: 59 additions & 0 deletions packages/editor/CodeMirror/markdown/fastLinksExtension.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { EditorSelection } from '@codemirror/state';
import createTestEditor from '../testUtil/createTestEditor';
import { EditorView } from '@codemirror/view';

const pasteText = (editor: EditorView, text: string) => {
const clipboardData = new DataTransfer();
clipboardData.setData('text/plain', text);
editor.contentDOM.dispatchEvent(new ClipboardEvent('paste', {
clipboardData,
}));
};

jest.retryTimes(2);

describe('fastLinksExtension', () => {
test('should convert selection to link when pasting URLs', async () => {
const testWithUrl = async (url: string) => {
const initialText = 'a test';
const editor = await createTestEditor(
initialText,
EditorSelection.range(2, 6), // selects "test"
[],
);

pasteText(editor, url);
const expected = `a [test](${url})`;
expect(editor.state.doc.toString()).toBe(expected);

// New content should be selected
expect(editor.state.selection.ranges).toHaveLength(1);
expect(editor.state.selection.main.from).toBe(2);
expect(editor.state.selection.main.to).toBe(editor.state.doc.length);

// Should replace if selection contains a URL
pasteText(editor, `${url}`);
expect(editor.state.doc.toString()).toBe(`a ${url}`);
};

await testWithUrl('http://example.com/');
await testWithUrl('https://example.com/');
await testWithUrl(':/someuuidhere');
});

test('should not convert selection to link when pasting non-urls', async () => {
const initialText = 'Test';
const editor = await createTestEditor(
initialText,
EditorSelection.range(0, initialText.length),
[],
);

pasteText(editor, 'test');
expect(editor.state.doc.toString()).toBe('test');

const notJustAUrl = 'this https://example.com/ is not just a URL';
pasteText(editor, notJustAUrl);
expect(editor.state.doc.toString()).toBe(`test${notJustAUrl}`);
});
});
62 changes: 62 additions & 0 deletions packages/editor/CodeMirror/markdown/fastLinksExtension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { EditorSelection, Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import intersectsAnySyntaxNodeOf from '../util/intersectsAnySyntaxNodeOf';

const fastLinksExtension: ()=> Extension = () => {
const eventHandlers = EditorView.domEventHandlers({
paste: (event, view) => {
if (view.state.selection.main.empty) {
return false;
}

// Don't try to paste as a link if files (e.g. images) could be
// pasted instead.
if (event.clipboardData?.files?.length > 0) {
return false;
}

const clipboardText = event.clipboardData?.getData('text/plain') ?? '';

const httpUrlRegex = /^https?:\/\/\S+$/;
const resourceUrlRegex = /^:\/[a-zA-Z0-9]+$/;
if (!httpUrlRegex.exec(clipboardText) && !resourceUrlRegex.exec(clipboardText)) {
return false;
}

// Don't linkify if the user could be trying to change an existing link
if (intersectsAnySyntaxNodeOf(view.state, view.state.selection.main, ['Link', 'Image', 'URL'])) {
return false;
}

view.dispatch(view.state.changeByRange(selection => {
const selectedText = view.state.sliceDoc(selection.from, selection.to);

if (selection.empty || selectedText.includes('\n')) {
return {
range: EditorSelection.range(selection.from, selection.from + clipboardText.length),
changes: [{
from: selection.from,
to: selection.to,
insert: clipboardText,
}],
};
} else {
const replaceWith = `[${selectedText}](${clipboardText})`;
return {
range: EditorSelection.range(selection.from, selection.from + replaceWith.length),
changes: [{
from: selection.from,
to: selection.to,
insert: replaceWith,
}],
};
}
}));

return true;
},
});
return eventHandlers;
};

export default fastLinksExtension;
4 changes: 2 additions & 2 deletions packages/editor/CodeMirror/markdown/markdownCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
toggleInlineFormatGlobally, toggleRegionFormatGlobally, toggleSelectedLinesStartWith,
isIndentationEquivalent, stripBlockquote, tabsToSpaces,
} from './markdownReformatter';
import intersectsSyntaxNode from '../util/isInSyntaxNode';
import intersectsAnySyntaxNodeOf from '../util/intersectsAnySyntaxNodeOf';

const startingSpaceRegex = /^(\s*)/;

Expand Down Expand Up @@ -434,7 +434,7 @@ export const insertOrIncreaseIndent: Command = (view: EditorView): boolean => {
}


if (intersectsSyntaxNode(view.state, mainSelection, 'ListItem')) {
if (intersectsAnySyntaxNodeOf(view.state, mainSelection, ['ListItem'])) {
return increaseIndent(view);
}

Expand Down
2 changes: 2 additions & 0 deletions packages/editor/CodeMirror/testUtil/createTestEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { EditorView } from '@codemirror/view';
import { MarkdownMathExtension } from '../markdown/markdownMathParser';
import forceFullParse from './forceFullParse';
import loadLangauges from './loadLanguages';
import fastLinksExtension from '../markdown/fastLinksExtension';

// Creates and returns a minimal editor with markdown extensions. Waits to return the editor
// until all syntax tree tags in `expectedSyntaxTreeTags` exist.
Expand All @@ -23,6 +24,7 @@ const createTestEditor = async (
}),
indentUnit.of('\t'),
EditorState.tabSize.of(4),
fastLinksExtension(),
],
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ interface Range {
to: number;
}

const intersectsSyntaxNode = (state: EditorState, range: Range, nodeName: string) => {
const intersectsAnySyntaxNodeOf = (state: EditorState, range: Range, nodeNames: string[]) => {
let foundNode = false;

syntaxTree(state).iterate({
from: range.from,
to: range.to,
enter: node => {
if (node.name === nodeName) {
if (nodeNames.includes(node.name)) {
foundNode = true;

// Skip children
Expand All @@ -28,5 +28,5 @@ const intersectsSyntaxNode = (state: EditorState, range: Range, nodeName: string
return foundNode;
};

export default intersectsSyntaxNode;
export default intersectsAnySyntaxNodeOf;

26 changes: 26 additions & 0 deletions packages/editor/jest.setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,29 @@ document.createRange = () => {

return range;
};

// We use DataTransfer and ClipboardEvent to mock paste events.
// They don't seem to be supported by jsdom, so we mock them here:
window.DataTransfer ??= class {
#data = new Map();
files = [];

setData(format, data) {
this.#data.set(format, data);
}

getData(format, data) {
return this.#data.get(format, data);
}

clearData(format) {
this.#data.delete(format);
}
};

window.ClipboardEvent ??= class extends Event {
constructor(type, init) {
super(type, init);
this.clipboardData = init.clipboardData ?? null;
}
};
Loading