diff --git a/demo/md-content.ts b/demo/md-content.ts index b72279da..6970a813 100644 --- a/demo/md-content.ts +++ b/demo/md-content.ts @@ -1,6 +1,17 @@ export const initialMdContent = ` # Это заголовок {#якорь} +{% cut "Заголовки" %} + +# Heading 1 +## Heading 2 +### Heading 3 +#### Heading 4 +##### Heading 5 +###### Heading 6 + +{% endcut %} + {% cut "Это заголовок ката" %} **А** *здесь* ~~его~~ ++крутой++ ^к^~о~^н^~т~^е^~н~^т^ diff --git a/package-lock.json b/package-lock.json index a0b51bb1..6218dca9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,13 +11,19 @@ "dependencies": { "@bem-react/classname": "^1.6.0", "@bem-react/classnames": "1.3.10", + "@codemirror/autocomplete": "6.16.0", + "@codemirror/commands": "6.5.0", + "@codemirror/lang-markdown": "6.2.5", + "@codemirror/language": "6.10.1", + "@codemirror/state": "6.4.1", + "@codemirror/view": "6.26.3", "@gravity-ui/i18n": "^1.1.0", "@gravity-ui/icons": "^2.0.0", - "@types/codemirror": "5.60.5", + "@lezer/highlight": "1.2.0", + "@lezer/markdown": "1.3.0", "@types/is-number": "^7.0.1", "@types/markdown-it": "^12.2.3", "base64-arraybuffer": "1.0.2", - "codemirror": "5.65.0", "is-number": "^7.0.0", "markdown-it-attrs": "4.1.4", "markdown-it-color": "^2.1.1", @@ -2375,6 +2381,128 @@ "integrity": "sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==", "dev": true }, + "node_modules/@codemirror/autocomplete": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.16.0.tgz", + "integrity": "sha512-P/LeCTtZHRTCU4xQsa89vSKWecYv1ZqwzOd5topheGRf+qtacFgBeIMQi3eL8Kt/BUNvxUWkx+5qP2jlGoARrg==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + }, + "peerDependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.5.0.tgz", + "integrity": "sha512-rK+sj4fCAN/QfcY9BEzYMgp4wwL/q5aj/VfNSoH1RWPF9XS/dUwBkvlL3hpWgEjOqlpdN1uLC9UkjJ4tmyjJYg==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.2.1.tgz", + "integrity": "sha512-/UNWDNV5Viwi/1lpr/dIXJNWiwDxpw13I4pTUAsNxZdg6E0mI2kTQb0P2iHczg1Tu+H4EBgJR+hYhKiHKko7qg==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz", + "integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz", + "integrity": "sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.2.5.tgz", + "integrity": "sha512-Hgke565YcO4fd9pe2uLYxnMufHO5rQwRr+AAhFq8ABuhkrjyX8R5p5s+hZUTdV60O0dMRjxKhBLxz8pu/MkUVA==", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.1.tgz", + "integrity": "sha512-5GrXzrhq6k+gL5fjkAwt90nYDmjlzTIJV8THnxNFtNKWotMIlzzN+CpqxqwXOECnUdOndmSeWntVrVcv5axWRQ==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.1.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.5.0.tgz", + "integrity": "sha512-+5YyicIaaAZKU8K43IQi8TBy6mF6giGeWAH7N96Z5LC30Wm5JMjqxOYIE9mxwMG1NbhT2mA3l9hA4uuKUM3E5g==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz", + "integrity": "sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==" + }, + "node_modules/@codemirror/view": { + "version": "6.26.3", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.26.3.tgz", + "integrity": "sha512-gmqxkPALZjkgSxIeeweY/wGQXBfwTUaLs8h7OKtSwfbj9Ct3L11lD+u1sS7XHppxFQoMDiMDp07P9f3I2jWOHw==", + "dependencies": { + "@codemirror/state": "^6.4.0", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -4319,6 +4447,66 @@ "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", "dev": true }, + "node_modules/@lezer/common": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz", + "integrity": "sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==" + }, + "node_modules/@lezer/css": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.8.tgz", + "integrity": "sha512-7JhxupKuMBaWQKjQoLtzhGj83DdnZY9MckEOG5+/iLKNK2ZJqKc6hf6uc0HjwCX7Qlok44jBNqZhHKDhEhZYLA==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.0.tgz", + "integrity": "sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.9", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.9.tgz", + "integrity": "sha512-MXxeCMPyrcemSLGaTQEZx0dBUH0i+RPl8RN5GwMAzo53nTsd/Unc/t5ZxACeQoyPUM5/GkPLRUs2WliOImzkRA==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.14.tgz", + "integrity": "sha512-GEdUyspTRgc5dwIGebUk+f3BekvqEWVIYsIuAC3pA8e8wcikGwBZRWRa450L0s8noGWuULwnmi4yjxTnYz9PpA==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.0.tgz", + "integrity": "sha512-Wst46p51km8gH0ZUmeNrtpRYmdlRHUpN1DQd3GFAyKANi8WVz8c2jHYTf1CVScFaCjQw1iO3ZZdqGDxQPRErTg==", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.3.0.tgz", + "integrity": "sha512-ErbEQ15eowmJUyT095e9NJc3BI9yZ894fjSDtHftD0InkfUBGgnKSU6dvan9jqsZuNHg2+ag/1oyDRxNsENupQ==", + "dependencies": { + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0" + } + }, "node_modules/@mdx-js/react": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-2.3.0.tgz", @@ -7327,14 +7515,6 @@ "@types/node": "*" } }, - "node_modules/@types/codemirror": { - "version": "5.60.5", - "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.5.tgz", - "integrity": "sha512-TiECZmm8St5YxjFUp64LK0c8WU5bxMDt9YaAek1UqUb9swrSCoJhh92fWu1p3mTEqlHjhB5sY7OFBhWroJXZVg==", - "dependencies": { - "@types/tern": "*" - } - }, "node_modules/@types/connect": { "version": "3.4.35", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", @@ -7442,7 +7622,8 @@ "node_modules/@types/estree": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz", - "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==" + "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==", + "dev": true }, "node_modules/@types/expect": { "version": "1.20.4", @@ -7850,14 +8031,6 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, - "node_modules/@types/tern": { - "version": "0.23.9", - "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", - "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", - "dependencies": { - "@types/estree": "*" - } - }, "node_modules/@types/undertaker": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@types/undertaker/-/undertaker-1.2.8.tgz", @@ -10824,11 +10997,6 @@ "dev": true, "peer": true }, - "node_modules/codemirror": { - "version": "5.65.0", - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.0.tgz", - "integrity": "sha512-gWEnHKEcz1Hyz7fsQWpK7P0sPI2/kSkRX2tc7DFA6TmZuDN75x/1ejnH/Pn8adYKrLEA1V2ww6L00GudHZbSKw==" - }, "node_modules/collect-v8-coverage": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", @@ -11189,6 +11357,11 @@ "node": ">=10" } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" + }, "node_modules/cross-spawn": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", @@ -25947,6 +26120,11 @@ "webpack": "^5.0.0" } }, + "node_modules/style-mod": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", + "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==" + }, "node_modules/style-search": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz", diff --git a/package.json b/package.json index cb76e104..fb44b840 100644 --- a/package.json +++ b/package.json @@ -143,13 +143,19 @@ "dependencies": { "@bem-react/classname": "^1.6.0", "@bem-react/classnames": "1.3.10", + "@codemirror/autocomplete": "6.16.0", + "@codemirror/commands": "6.5.0", + "@codemirror/lang-markdown": "6.2.5", + "@codemirror/language": "6.10.1", + "@codemirror/state": "6.4.1", + "@codemirror/view": "6.26.3", "@gravity-ui/i18n": "^1.1.0", "@gravity-ui/icons": "^2.0.0", - "@types/codemirror": "5.60.5", + "@lezer/highlight": "1.2.0", + "@lezer/markdown": "1.3.0", "@types/is-number": "^7.0.1", "@types/markdown-it": "^12.2.3", "base64-arraybuffer": "1.0.2", - "codemirror": "5.65.0", "is-number": "^7.0.0", "markdown-it-attrs": "4.1.4", "markdown-it-color": "^2.1.1", diff --git a/src/bundle/Editor.ts b/src/bundle/Editor.ts index 5ddf7287..b88402fe 100644 --- a/src/bundle/Editor.ts +++ b/src/bundle/Editor.ts @@ -1,6 +1,5 @@ import type {ReactNode} from 'react'; -import CodeMirror from 'codemirror'; import {TextSelection} from 'prosemirror-state'; import {EditorView as PMEditorView} from 'prosemirror-view'; @@ -13,15 +12,11 @@ import { import {ReactRenderStorage, RenderStorage} from '../extensions'; import {i18n} from '../i18n/bundle'; import {logger} from '../logger'; +import {createCodemirror} from '../markup/codemirror'; import {CodeEditor, Editor as MarkupEditor} from '../markup/editor'; -import {Action as A, formatter as f} from '../shortcuts'; -import {DataTransferType, isFilesFromHtml, isFilesOnly} from '../utils/clipboard'; import {Emitter, Receiver, SafeEventEmitter} from '../utils/event-emitter'; import type {FileUploadHandler} from '../utils/upload'; -import {config as cmBaseConfig} from './cm-config'; -import {CMFilesUploadManager, CMFilesUploader} from './cm-upload'; - export type EditorType = 'wysiwyg' | 'markup'; export type SplitMode = false | 'horizontal' | 'vertical'; export type RenderPreview = ({ @@ -32,20 +27,6 @@ export type RenderPreview = ({ mode: 'preview' | 'split'; }) => ReactNode; -const PAIRING_CHARS = new Map([ - ['(', ')'], - ['{', '}'], - ['[', ']'], - ['<', '>'], - - ['*', '*'], - ['~', '~'], - - ['"', '"'], - ["'", "'"], - ['`', '`'], -]); - interface EventMap { change: null; cancel: null; @@ -66,6 +47,7 @@ interface EventMap { interface EventMapInt extends EventMap { rerender: null; 'rerender-toolbar': null; + 'cm-scroll': {event: Event}; } export interface Editor extends Receiver, CommonEditor { @@ -175,8 +157,6 @@ export class EditorImpl extends SafeEventEmitter implements EditorI #needToSetDimensionsForUploadedImages: boolean; #prepareRawMarkup?: (value: MarkupString) => MarkupString; - #cmFilesUploader?: CMFilesUploader; - get _wysiwygView(): PMEditorView { // @ts-expect-error internal typing return this.#wysiwygEditor?.view; @@ -270,36 +250,20 @@ export class EditorImpl extends SafeEventEmitter implements EditorI get markupEditor(): MarkupEditor { if (!this.#markupEditor) { - const config: CodeMirror.EditorConfiguration = { - ...cmBaseConfig, - placeholder: i18n('markup_placeholder'), - extraKeys: { - ...(cmBaseConfig.extraKeys as {}), - [f.toCM(A.Cancel)!]: () => { - this.emit('cancel', null); - }, - [f.toCM(A.Submit)!]: () => { - this.emit('submit', null); - }, - }, - }; this.#markupEditor = new MarkupEditor( - CodeMirror(() => {}, {value: this.#markup, ...config}), - ); - this.#markupEditor.codemirror.on('changes', this.onCMChanges); - this.#markupEditor.codemirror.on('cursorActivity', this.onCMCursorActivity); - this.#markupEditor.codemirror.on('paste', this.onCMPaste); - this.#markupEditor.codemirror.on('drop', this.onCMDrop); - this.#markupEditor.codemirror.on('beforeChange', this.onCMBeforeChange); - - if (this.#fileUploadHandler) { - this.#cmFilesUploader = new CMFilesUploadManager({ - cm: this.cm, + createCodemirror({ + doc: this.#markup, + placeholderText: i18n('markup_placeholder'), + onCancel: () => this.emit('cancel', null), + onSubmit: () => this.emit('submit', null), + onChange: () => this.emit('rerender-toolbar', null), + onDocChange: () => this.emit('change', null), + onScroll: (event) => this.emit('cm-scroll', {event}), reactRenderer: this.#renderStorage, - uploadHandler: this.#fileUploadHandler, - needDimmensionsForImages: this.needToSetDimensionsForUploadedImages, - }); - } + uploadHandler: this.fileUploadHandler, + needImgDimms: this.needToSetDimensionsForUploadedImages, + }), + ); } return this.#markupEditor; } @@ -341,6 +305,7 @@ export class EditorImpl extends SafeEventEmitter implements EditorI } // ---> implements CodeEditor + get cm() { return this.markupEditor.cm; } @@ -359,22 +324,12 @@ export class EditorImpl extends SafeEventEmitter implements EditorI // <--- implements ActionStorage destroy() { - if (this.#markupEditor) { - this.#markupEditor.codemirror.off('changes', this.onCMChanges); - this.#markupEditor.codemirror.off('cursorActivity', this.onCMCursorActivity); - this.#markupEditor.codemirror.off('paste', this.onCMPaste); - this.#markupEditor.codemirror.off('drop', this.onCMDrop); - this.#markupEditor.codemirror.off('beforeChange', this.onCMBeforeChange); - this.#markupEditor.codemirror.getWrapperElement().remove(); - } - this.#wysiwygEditor?.destroy(); + this.#markupEditor?.codemirror.destroy(); + this.#markupEditor = undefined; this.#markupEditor = undefined; this.#wysiwygEditor = undefined; - - this.#cmFilesUploader?.destroy(); - this.#cmFilesUploader = undefined; } setEditorType(type: EditorType): void { @@ -446,7 +401,12 @@ export class EditorImpl extends SafeEventEmitter implements EditorI switch (type) { case 'markup': { - this.cm.setCursor(line, 0, {scroll: true}); + const lineNumber = line + 1; + const view = this.markupEditor.cm; + if (lineNumber > 0 && lineNumber <= view.state.doc.lines) { + view.dispatch({selection: {anchor: view.state.doc.line(lineNumber).from}}); + } + break; } case 'wysiwyg': { @@ -469,88 +429,6 @@ export class EditorImpl extends SafeEventEmitter implements EditorI } } - private onCMCursorActivity = () => { - this.emit('rerender-toolbar', null); - }; - - private onCMChanges = () => { - this.emit('change', null); - }; - - private onCMPaste = (cm: CodeMirror.Editor, event: ClipboardEvent) => { - if (!event.clipboardData) return; - const {clipboardData} = event; - - const fromWysiwyg = clipboardData.types.includes(DataTransferType.Yfm); - if (fromWysiwyg) { - const markup = clipboardData.getData(DataTransferType.Yfm); - cm.replaceSelection(markup); - event.preventDefault(); - return; - } - - if (clipboardData.files.length) { - this.#cmFilesUploader?.upload(clipboardData.files); - } - - if (isFilesOnly(clipboardData) || isFilesFromHtml(clipboardData)) { - event.preventDefault(); - } - }; - - private onCMDrop = (cm: CodeMirror.Editor, event: DragEvent) => { - if (!event.dataTransfer) return; - - const { - dataTransfer: {files}, - } = event; - - if (files.length) { - event.preventDefault(); - const pos = cm.coordsChar({left: event.pageX, top: event.pageY}, 'page'); - cm.setCursor(pos); - this.#cmFilesUploader?.upload(files); - } - }; - - private onCMBeforeChange = ( - _cm: CodeMirror.Editor, - event: CodeMirror.EditorChangeCancellable, - ) => { - const {text, to, from, cancel} = event; - const selection = this.cm.getSelection(); - const sameLine = from.line === to.line; - const newLine = selection.endsWith('\n'); - - if ( - !PAIRING_CHARS.has(text[0]) || - event.origin !== '+input' || - selection.trim().length <= 0 - ) - return; - - cancel(); - - const replacement = - text[0] + - // If there was a line break at the end, remove it and place it behind the closing paired character - (newLine ? selection.replace(/\n+$/, '') : selection) + - PAIRING_CHARS.get(text[0]) + - (newLine ? '\n' : ''); - - this.cm.replaceSelection(replacement); - - // If paired characters were inserted on the same line, then 2 characters will be added - // if not, then one closing. - let ch = sameLine ? to.ch + 2 : to.ch + 1; - if (to.ch === 0) ch = 0; - - this.cm.addSelection(from, { - line: to.line, - ch, - }); - }; - private shouldReplaceMarkupEditorValue(markupValue: string, wysiwygValue: string) { const serializedEditorMarkup = this.#wysiwygEditor?.serializer.serialize( this.#wysiwygEditor.parser.parse(markupValue), diff --git a/src/bundle/HorizontalDrag.tsx b/src/bundle/HorizontalDrag.tsx index 29809a20..e649f211 100644 --- a/src/bundle/HorizontalDrag.tsx +++ b/src/bundle/HorizontalDrag.tsx @@ -89,6 +89,8 @@ const HorizontalDrag: React.FC = ({ wrapperRef, editor, }) => { + const cm = editor.cm; + const [lCardWidth, lSetCardWidth] = useState((wrapperRef.current?.clientWidth ?? 0) / 2); const [rCardWidth, rSetCardWidth] = useState((wrapperRef.current?.clientWidth ?? 0) / 2); @@ -119,7 +121,7 @@ const HorizontalDrag: React.FC = ({ // Set initially calculated width useEffect(() => { updateWidth(lCardWidth, rCardWidth); - editor.cm.refresh(); + cm.requestMeasure(); }, []); useEffect(() => { @@ -157,12 +159,12 @@ const HorizontalDrag: React.FC = ({ updateWidth(lNewWidth, rNewWidth); wrapperRef.current?.style.removeProperty('user-select'); - editor.cm.refresh(); + cm.requestMeasure(); rightElRef.current?.classList.remove(IN_RESIZE_CLASSNAME); leftElRef.current?.classList.remove(IN_RESIZE_CLASSNAME); }, - [calculateWidth, editor.cm, leftElRef, rightElRef, updateWidth, wrapperRef], + [calculateWidth, cm, leftElRef, rightElRef, updateWidth, wrapperRef], ); const {listeners} = useColResize({onStart, onMove, onEnd}); diff --git a/src/bundle/MarkupEditorComponent.tsx b/src/bundle/MarkupEditorComponent.tsx index 13cf2dcf..52d7ce3c 100644 --- a/src/bundle/MarkupEditorComponent.tsx +++ b/src/bundle/MarkupEditorComponent.tsx @@ -15,7 +15,7 @@ export const MarkupEditorComponent: React.FC = // insert editor to dom React.useLayoutEffect(() => { - const domElem = editor.markupEditor.cm.getWrapperElement(); + const domElem = editor.markupEditor.cm.dom; if (ref.current) { ref.current.appendChild(domElem); } @@ -26,7 +26,7 @@ export const MarkupEditorComponent: React.FC = // update editor after connecting to dom React.useEffect(() => { - editor.markupEditor.cm.refresh(); + editor.markupEditor.cm.requestMeasure(); if (autofocus) { editor.markupEditor.focus(); } @@ -40,7 +40,7 @@ export const MarkupEditorComponent: React.FC = onClick={(event) => { const target = event.target; - if (target instanceof Element && target.classList.contains('CodeMirror')) { + if (target instanceof Element && target.classList.contains('cm-editor')) { editor.markupEditor.focus(); } }} diff --git a/src/bundle/SplitModeView.tsx b/src/bundle/SplitModeView.tsx index a2aa98ea..8330e8df 100644 --- a/src/bundle/SplitModeView.tsx +++ b/src/bundle/SplitModeView.tsx @@ -2,7 +2,6 @@ import React, {useEffect, useImperativeHandle, useMemo, useReducer, useRef} from import {Eye} from '@gravity-ui/icons'; import {Label} from '@gravity-ui/uikit'; -import {Editor} from 'codemirror'; import {cn} from '../classname'; import {i18n} from '../i18n/bundle'; @@ -13,7 +12,7 @@ import { scrollToRevealSourceLine, } from '../utils/sync-scroll'; -import {EditorInt} from './Editor'; +import type {EditorInt} from './Editor'; const b = cn('markup-preview'); @@ -22,10 +21,11 @@ export type SplitModeProps = { }; const SplitModeView = React.forwardRef(({editor}, ref) => { + const cm = editor.cm; + const [, forceUpdate] = useReducer((x) => x + 1, 0); const outerRef = useRef(null); const lineElements = useRef([]); - const shouldScroll = useRef(true); const updateLineElements = () => { if (!outerRef.current) return; @@ -41,61 +41,55 @@ const SplitModeView = React.forwardRef(({editor} const handleEditorScroll = useMemo( () => - throttle((instance: Editor) => { + throttle(() => { if (!outerRef.current) return; - if (!shouldScroll.current) { - shouldScroll.current = true; - return; - } - shouldScroll.current = false; - const line = instance.lineAtHeight(instance.getScrollInfo().top, 'local'); + if (!cm.dom.matches(':hover')) return; + + const {range} = cm.scrollSnapshot().value; + const line = cm.state.doc.lineAt(range.from).number - 1; + updateLineElements(); - scrollToRevealSourceLine( - Math.min(line === 0 ? 0 : line + 1, instance.lineCount() - 1), - lineElements.current, - outerRef, - ); + scrollToRevealSourceLine(line, lineElements.current, outerRef); }, 30), - [], + [cm], ); const handlePreviewScroll = useMemo( () => throttle(() => { if (!outerRef.current) return; - if (!shouldScroll.current) { - shouldScroll.current = true; - return; - } - shouldScroll.current = false; + if (!outerRef.current.matches(':hover')) return; + updateLineElements(); - let line = getEditorLineNumberForOffset( + let lineNumber = getEditorLineNumberForOffset( outerRef.current.scrollTop, lineElements.current, outerRef, ); - if (line === null || isNaN(line)) return; - line = Math.max(Math.floor(line) - 1, 0); - const scrollTo = editor.markupEditor.cm.heightAtLine(line, 'local'); - editor.markupEditor.cm.scrollTo(null, scrollTo); + + if (lineNumber === null || isNaN(lineNumber)) return; + lineNumber = Math.max(Math.floor(lineNumber), 0) + 1; + + const line = cm.state.doc.line(lineNumber); + const {top} = cm.lineBlockAt(line.from); + cm.scrollDOM.scrollTo({top}); }, 30), - [editor.markupEditor.cm], + [cm], ); useEffect(() => { const outer = outerRef.current; editor.on('change', forceUpdate); - - editor.markupEditor.cm.on('scroll', handleEditorScroll); + editor.on('cm-scroll', handleEditorScroll); outer?.addEventListener('scroll', handlePreviewScroll); - editor.markupEditor.cm.refresh(); + editor.cm.requestMeasure(); return () => { editor.off('change', forceUpdate); - editor.markupEditor.cm.off('scroll', handleEditorScroll); + editor.off('cm-scroll', handleEditorScroll); outer?.removeEventListener('scroll', handlePreviewScroll); }; }, [editor, handleEditorScroll, handlePreviewScroll]); diff --git a/src/bundle/cm-config.ts b/src/bundle/cm-config.ts deleted file mode 100644 index 4d01950e..00000000 --- a/src/bundle/cm-config.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type CodeMirror from 'codemirror'; -import {Pass} from 'codemirror'; -import 'codemirror/addon/display/placeholder'; -import 'codemirror/addon/edit/continuelist'; -// import 'codemirror/addon/fold/foldcode'; -// import 'codemirror/addon/fold/foldgutter'; -// import 'codemirror/addon/fold/foldgutter.css'; -import 'codemirror/addon/fold/markdown-fold'; -// import 'codemirror/addon/selection/active-line'; -import 'codemirror/mode/markdown/markdown'; - -import {logger} from '../logger'; -import { - insertLink, - toCodeBlock, - toH1, - toH2, - toH3, - toH4, - toH5, - toH6, - toInlineCode, - wrapToBold, - wrapToCut, - wrapToItalic, - wrapToNote, - wrapToStrike, - wrapToUnderline, -} from '../markup/commands'; -import {Action as A, formatter as f} from '../shortcuts'; - -import {ActionName} from './config/action-names'; - -import 'codemirror/lib/codemirror.css'; - -export const config: CodeMirror.EditorConfiguration = { - lineNumbers: false, - lineWrapping: true, - // foldGutter: true, - mode: 'text/markdown', - viewportMargin: Infinity, - // gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'], - extraKeys: { - Enter: 'newlineAndIndentContinueMarkdownList', - [f.toCM(A.Bold)!]: withLogger(ActionName.bold, wrapToBold), - [f.toCM(A.Italic)!]: withLogger(ActionName.italic, wrapToItalic), - [f.toCM(A.Strike)!]: withLogger(ActionName.strike, wrapToStrike), - [f.toCM(A.Underline)!]: withLogger(ActionName.underline, wrapToUnderline), - [f.toCM(A.Link)!]: withLogger(ActionName.link, insertLink({url: ''})), - [f.toCM(A.Heading1)!]: withLogger(ActionName.heading1, toH1), - [f.toCM(A.Heading2)!]: withLogger(ActionName.heading2, toH2), - [f.toCM(A.Heading3)!]: withLogger(ActionName.heading3, toH3), - [f.toCM(A.Heading4)!]: withLogger(ActionName.heading4, toH4), - [f.toCM(A.Heading5)!]: withLogger(ActionName.heading5, toH5), - [f.toCM(A.Heading6)!]: withLogger(ActionName.heading6, toH6), - [f.toCM(A.Code)!]: withLogger(ActionName.code_inline, toInlineCode), - [f.toCM(A.CodeBlock)!]: withLogger(ActionName.code_block, toCodeBlock), - [f.toCM(A.Cut)!]: withLogger(ActionName.yfm_cut, wrapToCut), - [f.toCM(A.Note)!]: withLogger(ActionName.yfm_note, wrapToNote), - }, - // styleActiveLine: true, - addModeClass: true, -}; - -type CMCommand = (instance: CodeMirror.Editor) => void | typeof CodeMirror.Pass; - -export function withLogger(action: string, command: CMCommand): CMCommand { - return (...args) => { - const res = command(...args); - if (res !== Pass) { - logger.action({mode: 'markup', source: 'keymap', action}); - } - return res; - }; -} diff --git a/src/bundle/cm-upload/FilesUploadManager.ts b/src/bundle/cm-upload/FilesUploadManager.ts deleted file mode 100644 index d4152c61..00000000 --- a/src/bundle/cm-upload/FilesUploadManager.ts +++ /dev/null @@ -1,46 +0,0 @@ -import {CMFilesUploadPresenter, CMFilesUploadPresenterParams} from './FilesUploadPresenter'; - -export interface CMFilesUploader { - upload(files: ArrayLike): void; - destroy(): void; -} - -export type CMFilesUploadManagerParams = Pick< - CMFilesUploadPresenterParams, - 'cm' | 'uploadHandler' | 'reactRenderer' | 'needDimmensionsForImages' ->; - -export class CMFilesUploadManager implements CMFilesUploader, CMFilesUploadPresenterParams { - readonly cm: CMFilesUploadManagerParams['cm']; - readonly uploadHandler: CMFilesUploadManagerParams['uploadHandler']; - readonly reactRenderer: CMFilesUploadManagerParams['reactRenderer']; - readonly needDimmensionsForImages: boolean; - - private readonly _presenters: CMFilesUploadPresenter[] = []; - - constructor(params: CMFilesUploadManagerParams) { - this.cm = params.cm; - this.uploadHandler = params.uploadHandler; - this.reactRenderer = params.reactRenderer; - this.needDimmensionsForImages = params.needDimmensionsForImages; - } - - upload(files: ArrayLike) { - this._presenters.push(new CMFilesUploadPresenter(this).run(files)); - } - - destroy() { - for (const presenter of this._presenters) { - presenter.destroy(); - } - this._presenters.length = 0; - } - - onAllSettled(presenter: CMFilesUploadPresenter): void { - presenter.destroy(); - const index = this._presenters.indexOf(presenter); - if (index >= 0) { - this._presenters.splice(index, 1); - } - } -} diff --git a/src/bundle/cm-upload/FilesUploadPresenter.ts b/src/bundle/cm-upload/FilesUploadPresenter.ts deleted file mode 100644 index 80917c98..00000000 --- a/src/bundle/cm-upload/FilesUploadPresenter.ts +++ /dev/null @@ -1,214 +0,0 @@ -import type CodeMirror from 'codemirror'; - -import {ReactRenderStorage, RendererItem} from '../../extensions'; -import {isImageFile} from '../../utils/clipboard'; -import type {FileUploadHandler, FileUploadResult} from '../../utils/upload'; - -import {UploadedFile, renderUploadWidget} from './FilesUploadWidget'; -import {IMG_MAX_HEIGHT} from './const'; -import {getImageDimensions} from './utils'; - -const SUCCESS_UPLOAD_REMOVE_TIMEOUT = 1000; // 1 sec -const enum State { - Initial, - Running, - Destroyed, -} - -export interface CMFilesUploadPresenterParams { - readonly cm: CodeMirror.Editor; - readonly uploadHandler: FileUploadHandler; - readonly reactRenderer: ReactRenderStorage; - onAllSettled(presenter: CMFilesUploadPresenter): void; - needDimmensionsForImages: boolean; -} - -export class CMFilesUploadPresenter { - private readonly _params: CMFilesUploadPresenterParams; - - private readonly _uploadFiles: (UploadedFile & {file: File})[] = []; - - private __reactWidget: RendererItem | null = null; - private readonly _reactContainer: HTMLElement; - private _cmWidget: CodeMirror.LineWidget | null = null; - - private readonly _initialPosition: CodeMirror.Position; - - private _state: State = State.Initial; - - private get _reactWidget(): RendererItem { - if (this.__reactWidget) return this.__reactWidget; - this.__reactWidget = this._params.reactRenderer.createItem( - 'cm-upload-files', - renderUploadWidget(this._reactContainer, { - files: this._uploadFiles, - onReUploadClick: this._onReUploadClick.bind(this), - onCloseClick: this._onCloseClick.bind(this), - }), - ); - return this.__reactWidget; - } - - constructor(params: CMFilesUploadPresenterParams) { - this._params = params; - - this._reactContainer = document.createElement('div'); - this._reactContainer.classList.add('cm-widget-container'); - - this._initialPosition = this._params.cm.getCursor(); - } - - run(files: ArrayLike) { - if (this._state !== State.Initial) return this; - this._state = State.Running; - - for (const file of Array.from(files)) { - this._uploadFiles.push({ - file, - fileType: isImageFile(file) ? 'image' : 'file', - fileName: file.name, - status: 'uploading', - }); - - this._params.uploadHandler(file).then( - (res) => this._onFileUpload({...res, file}), - (err: unknown) => this._onFileFail({file, err}), - ); - } - - this._cmWidget = this._params.cm.addLineWidget( - this._initialPosition.line, - this._reactContainer, - {}, - ); - this._reactWidget.rerender(); - this._scheduleWidgetChange(); - - return this; - } - - destroy() { - this._state = State.Destroyed; - - this.__reactWidget?.remove(); - this.__reactWidget = null; - - this._cmWidget?.clear(); - this._cmWidget = null; - } - - private _onFileUpload(res: FileUploadResult & {file: File}) { - if (this._state === State.Destroyed) return; - - const file = this._uploadFiles.find((item) => item.file === res.file); - if (file) { - file.status = 'success'; - this._reactWidget.rerender(); - } - - setTimeout(() => { - this._insertFileMarkup(res); - this._removeFileFromView(res.file); - }, SUCCESS_UPLOAD_REMOVE_TIMEOUT); - } - - private _onFileFail(res: {file: File; err: unknown}) { - if (this._state === State.Destroyed) return; - - const file = this._uploadFiles.find((item) => item.file === res.file); - if (file) { - file.status = 'error'; - file.errorText = String(res.err); - this._reactWidget.rerender(); - } - } - - private _onReUploadClick(uFile: UploadedFile) { - if (uFile.status !== 'error') return; - - const file = this._uploadFiles.find((f) => f === uFile); - if (file) { - this._params.uploadHandler(file.file).then( - (res) => this._onFileUpload({...res, file: file.file}), - (err: unknown) => this._onFileFail({file: file.file, err}), - ); - - file.status = 'uploading'; - file.errorText = undefined; - this._reactWidget.rerender(); - } - } - - private _onCloseClick() { - this._params.onAllSettled(this); - } - - private async _insertFileMarkup(res: FileUploadResult & {file: File}) { - const fileName = res.name ?? res.file.name ?? ''; - - let markup: string; - if (isImageFile(res.file)) { - if (this._params.needDimmensionsForImages) { - try { - let {height} = await getImageDimensions(res.file); - height = Math.min(height, IMG_MAX_HEIGHT); - markup = `![${fileName}](${res.url} =x${height})`; - } catch (err) { - markup = `![${fileName}](${res.url})`; - } - } else { - markup = `![${fileName}](${res.url})`; - } - } else { - markup = `{% file src="${res.url}" name="${fileName.replace('"', '')}" %}`; - } - - const isInitPosition = this._isInitialCursorPos(this._params.cm.getCursor()); - - this._params.cm.replaceRange( - isInitPosition ? markup + ' ' : ' ' + markup, - isInitPosition - ? this._initialPosition - : {line: this._initialPosition.line, ch: Number.MAX_SAFE_INTEGER}, - ); - - if (isInitPosition) { - this._initialPosition.ch += markup.length + 1; - this._params.cm.setCursor(this._initialPosition); - } - } - - private _removeFileFromView(file: File) { - if (this._state === State.Destroyed) return; - - const index = this._uploadFiles.findIndex((item) => item.file === file); - if (index >= 0) { - this._uploadFiles.splice(index, 1); - this._reactWidget.rerender(); - this._scheduleWidgetChange(); - - this._checkIfAllFilesUploaded(); - } - } - - private _checkIfAllFilesUploaded() { - if (this._state === State.Destroyed) return; - - if (!this._uploadFiles.length) { - this._params.onAllSettled(this); - } - } - - private _isInitialCursorPos(currentPosition: CodeMirror.Position): boolean { - return ( - this._initialPosition.ch === currentPosition.ch && - this._initialPosition.line === currentPosition.line - ); - } - - private _scheduleWidgetChange() { - setTimeout(() => { - this._cmWidget?.changed(); - }, 20); - } -} diff --git a/src/bundle/cm-upload/FilesUploadWidget.scss b/src/bundle/cm-upload/FilesUploadWidget.scss deleted file mode 100644 index d0cda8ea..00000000 --- a/src/bundle/cm-upload/FilesUploadWidget.scss +++ /dev/null @@ -1,35 +0,0 @@ -@use '~@gravity-ui/uikit/styles/mixins.scss'; - -.ye-upload-widget { - display: flex; - justify-content: space-between; - - padding: 4px 6px; - - border-radius: var(--g-border-radius-xs); - background-color: var(--g-color-base-misc-light); - - &__labels { - display: inline-flex; - flex-wrap: wrap; - align-items: center; - gap: 2px; - } - - &__close-button { - margin-left: 4px; - } -} - -.ye-upload-label { - &__content { - display: flex; - align-items: center; - column-gap: 4px; - } - - &__filename { - display: inline-block; - @include mixins.max-text-width(128px); - } -} diff --git a/src/bundle/cm-upload/const.ts b/src/bundle/cm-upload/const.ts deleted file mode 100644 index 5894fb7a..00000000 --- a/src/bundle/cm-upload/const.ts +++ /dev/null @@ -1 +0,0 @@ -export const IMG_MAX_HEIGHT = 600; //px diff --git a/src/bundle/cm-upload/index.ts b/src/bundle/cm-upload/index.ts deleted file mode 100644 index 1b17fe72..00000000 --- a/src/bundle/cm-upload/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './FilesUploadManager'; diff --git a/src/bundle/config/markup.tsx b/src/bundle/config/markup.tsx index 89ea6456..11f3b135 100644 --- a/src/bundle/config/markup.tsx +++ b/src/bundle/config/markup.tsx @@ -2,45 +2,39 @@ import React from 'react'; import {i18n} from '../../i18n/menubar'; import { - isBoldActive, - isH1Active, - isH2Active, - isH3Active, - isH4Active, - isH5Active, - isH6Active, - isItalicActive, -} from '../../markup/active'; -import { + insertHRule, insertLink, - insertTable, + insertMermaidDiagram, + insertYfmTable, + insertYfmTabs, liftListItem, + redo, + redoDepth, sinkListItem, toBulletList, - toCheckbox, - toCodeBlock, toH1, toH2, toH3, toH4, toH5, toH6, - toHr, - toInlineCode, - toMermaid, toOrderedList, - toQuote, - toTabs, - wrapToBold, - wrapToCut, - wrapToItalic, - wrapToMark, + toggleBold, + toggleItalic, + toggleMarked, + toggleMonospace, + toggleStrikethrough, + toggleUnderline, + undo, + undoDepth, + wrapToBlockquote, + wrapToCheckbox, + wrapToCodeBlock, + wrapToInlineCode, wrapToMathBlock, wrapToMathInline, - wrapToMonospace, - wrapToNote, - wrapToStrike, - wrapToUnderline, + wrapToYfmCut, + wrapToYfmNote, } from '../../markup/commands'; import {CodeEditor} from '../../markup/editor'; import {Action as A, formatter as f} from '../../shortcuts'; @@ -48,13 +42,11 @@ import {ToolbarData} from '../../toolbar/Toolbar'; import {ToolbarGroupData} from '../../toolbar/ToolbarGroup'; import {ToolbarListButtonData} from '../../toolbar/ToolbarListButton'; import { - ToolbarButtonPopupData, ToolbarDataType, ToolbarListItemData, ToolbarReactComponentData, ToolbarSingleItemData, } from '../../toolbar/types'; -import {ToolbarLinkPopup} from '../toolbar/custom/ToolbarLinkPopup'; import {MToolbarColors} from '../toolbar/markup/MToolbarColors'; import {MToolbarFilePopup} from '../toolbar/markup/MToolbarFilePopup'; import {MToolbarImagePopup} from '../toolbar/markup/MToolbarImagePopup'; @@ -80,9 +72,9 @@ export const mHistoryGroupConfig: MToolbarGroupData = [ title: i18n.bind(null, 'undo'), icon: icons.undo, hotkey: f.toView(A.Undo), - exec: (e) => e.cm.undo(), + exec: (e) => undo(e.cm), isActive: isActiveFn, - isEnable: (e) => e.cm.historySize().undo > 0, + isEnable: (e) => undoDepth(e.cm.state) > 0, }, { id: ActionName.redo, @@ -90,9 +82,9 @@ export const mHistoryGroupConfig: MToolbarGroupData = [ title: i18n.bind(null, 'redo'), icon: icons.redo, hotkey: f.toView(A.Redo), - exec: (e) => e.cm.redo(), + exec: (e) => redo(e.cm), isActive: isActiveFn, - isEnable: (e) => e.cm.historySize().redo > 0, + isEnable: (e) => redoDepth(e.cm.state) > 0, }, ]; @@ -104,8 +96,8 @@ export const mBoldGroupItem: MToolbarSingleItemData = { title: i18n.bind(null, 'bold'), icon: icons.bold, hotkey: f.toView(A.Bold), - exec: (e) => wrapToBold(e.cm), - isActive: (e) => isBoldActive(e.cm), + exec: (e) => toggleBold(e.cm), + isActive: isActiveFn, isEnable: isEnableFn, }; @@ -115,8 +107,8 @@ export const mItalicGroupItem: MToolbarSingleItemData = { title: i18n.bind(null, 'italic'), icon: icons.italic, hotkey: f.toView(A.Italic), - exec: (e) => wrapToItalic(e.cm), - isActive: (e) => isItalicActive(e.cm), + exec: (e) => toggleItalic(e.cm), + isActive: isActiveFn, isEnable: isEnableFn, }; @@ -129,7 +121,7 @@ export const mBiusGroupConfig: MToolbarGroupData = [ title: i18n.bind(null, 'underline'), icon: icons.underline, hotkey: f.toView(A.Underline), - exec: (e) => wrapToUnderline(e.cm), + exec: (e) => toggleUnderline(e.cm), isActive: isActiveFn, isEnable: isEnableFn, }, @@ -139,7 +131,7 @@ export const mBiusGroupConfig: MToolbarGroupData = [ title: i18n.bind(null, 'strike'), icon: icons.strikethrough, hotkey: f.toView(A.Strike), - exec: (e) => wrapToStrike(e.cm), + exec: (e) => toggleStrikethrough(e.cm), isActive: isActiveFn, isEnable: isEnableFn, }, @@ -148,7 +140,7 @@ export const mBiusGroupConfig: MToolbarGroupData = [ type: ToolbarDataType.SingleButton, title: i18n.bind(null, 'mono'), icon: icons.mono, - exec: (e) => wrapToMonospace(e.cm), + exec: (e) => toggleMonospace(e.cm), isActive: isActiveFn, isEnable: isEnableFn, }, @@ -157,7 +149,7 @@ export const mBiusGroupConfig: MToolbarGroupData = [ type: ToolbarDataType.SingleButton, title: i18n.bind(null, 'mark'), icon: icons.mark, - exec: (e) => wrapToMark(e.cm), + exec: (e) => toggleMarked(e.cm), isActive: isActiveFn, isEnable: isEnableFn, }, @@ -174,7 +166,7 @@ export const mHeadingListConfig: MToolbarListButtonData = { icon: icons.h1, hotkey: f.toView(A.Heading1), exec: (e) => toH1(e.cm), - isActive: (e) => isH1Active(e.cm), + isActive: isActiveFn, isEnable: isEnableFn, }, { @@ -183,7 +175,7 @@ export const mHeadingListConfig: MToolbarListButtonData = { icon: icons.h2, hotkey: f.toView(A.Heading2), exec: (e) => toH2(e.cm), - isActive: (e) => isH2Active(e.cm), + isActive: isActiveFn, isEnable: isEnableFn, }, { @@ -192,7 +184,7 @@ export const mHeadingListConfig: MToolbarListButtonData = { icon: icons.h3, hotkey: f.toView(A.Heading3), exec: (e) => toH3(e.cm), - isActive: (e) => isH3Active(e.cm), + isActive: isActiveFn, isEnable: isEnableFn, }, { @@ -201,7 +193,7 @@ export const mHeadingListConfig: MToolbarListButtonData = { icon: icons.h4, hotkey: f.toView(A.Heading4), exec: (e) => toH4(e.cm), - isActive: (e) => isH4Active(e.cm), + isActive: isActiveFn, isEnable: isEnableFn, }, { @@ -210,7 +202,7 @@ export const mHeadingListConfig: MToolbarListButtonData = { icon: icons.h5, hotkey: f.toView(A.Heading5), exec: (e) => toH5(e.cm), - isActive: (e) => isH5Active(e.cm), + isActive: isActiveFn, isEnable: isEnableFn, }, { @@ -219,7 +211,7 @@ export const mHeadingListConfig: MToolbarListButtonData = { icon: icons.h6, hotkey: f.toView(A.Heading6), exec: (e) => toH6(e.cm), - isActive: (e) => isH6Active(e.cm), + isActive: isActiveFn, isEnable: isEnableFn, }, ], @@ -254,7 +246,7 @@ export const mCheckboxButton: MToolbarSingleItemData = { type: ToolbarDataType.SingleButton, title: i18n.bind(null, 'checkbox'), icon: icons.checklist, - exec: (e) => toCheckbox(e.cm), + exec: (e) => wrapToCheckbox(e.cm), isActive: isActiveFn, isEnable: isEnableFn, }; @@ -283,29 +275,14 @@ export const mListMoveListConfig: MToolbarListButtonData = { ], }; -export const mLinkButton: ToolbarButtonPopupData = { +export const mLinkButton: MToolbarSingleItemData = { id: ActionName.link, - type: ToolbarDataType.ButtonPopup, + type: ToolbarDataType.SingleButton, icon: icons.link, title: i18n('link'), - exec: noop, + exec: (e) => insertLink(e.cm), isActive: isActiveFn, isEnable: isEnableFn, - renderPopup: (props) => { - const {editor} = props; - const selection = editor.cm.getSelection(); - - return ( - { - insertLink({url, text})(editor.cm); - }} - /> - ); - }, }; export const mNoteButton: MToolbarSingleItemData = { @@ -314,7 +291,7 @@ export const mNoteButton: MToolbarSingleItemData = { title: i18n.bind(null, 'note'), icon: icons.note, hotkey: f.toView(A.Note), - exec: (e) => wrapToNote(e.cm), + exec: (e) => wrapToYfmNote(e.cm), isActive: isActiveFn, isEnable: isEnableFn, }; @@ -324,7 +301,7 @@ export const mQuoteButton: MToolbarSingleItemData = { type: ToolbarDataType.SingleButton, title: i18n.bind(null, 'quote'), icon: icons.quote, - exec: (e) => toQuote(e.cm), + exec: (e) => wrapToBlockquote(e.cm), isActive: isActiveFn, isEnable: isEnableFn, }; @@ -335,7 +312,7 @@ export const mCutButton: MToolbarSingleItemData = { title: i18n.bind(null, 'cut'), icon: icons.cut, hotkey: f.toView(A.Cut), - exec: (e) => wrapToCut(e.cm), + exec: (e) => wrapToYfmCut(e.cm), isActive: isActiveFn, isEnable: isEnableFn, }; @@ -345,7 +322,7 @@ export const mTableButton: MToolbarSingleItemData = { type: ToolbarDataType.SingleButton, title: i18n.bind(null, 'table'), icon: icons.table, - exec: (e) => insertTable(e.cm), + exec: (e) => insertYfmTable(e.cm), isActive: isActiveFn, isEnable: isEnableFn, }; @@ -360,7 +337,7 @@ export const mCodeListConfig: MToolbarListButtonData = { title: i18n.bind(null, 'code_inline'), icon: icons.code, hotkey: f.toView(A.Code), - exec: (e) => toInlineCode(e.cm), + exec: (e) => wrapToInlineCode(e.cm), isActive: isActiveFn, isEnable: isEnableFn, }, @@ -369,7 +346,7 @@ export const mCodeListConfig: MToolbarListButtonData = { title: i18n.bind(null, 'codeblock'), icon: icons.codeBlock, hotkey: f.toView(A.CodeBlock), - exec: (e) => toCodeBlock(e.cm), + exec: (e) => wrapToCodeBlock(e.cm), isActive: isActiveFn, isEnable: isEnableFn, }, @@ -411,7 +388,7 @@ export const mMermaidButton: MToolbarSingleItemData = { type: ToolbarDataType.SingleButton, title: i18n.bind(null, 'mermaid'), icon: icons.mermaid, - exec: (e) => toMermaid(e.cm), + exec: (e) => insertMermaidDiagram(e.cm), isActive: isActiveFn, isEnable: isEnableFn, }; @@ -478,8 +455,8 @@ export const mHruleItemData: MToolbarSingleItemData = { title: i18n.bind(null, 'hrule'), icon: icons.horizontalRule, type: ToolbarDataType.SingleButton, - exec: (e) => toHr(e.cm), - isActive: (e) => isBoldActive(e.cm), + exec: (e) => insertHRule(e.cm), + isActive: isActiveFn, isEnable: isEnableFn, }; @@ -488,8 +465,8 @@ export const mTabsItemData: MToolbarSingleItemData = { title: i18n.bind(null, 'tabs'), icon: icons.tabs, type: ToolbarDataType.SingleButton, - exec: (e) => toTabs(e.cm), - isActive: (e) => isBoldActive(e.cm), + exec: (e) => insertYfmTabs(e.cm), + isActive: isActiveFn, isEnable: isEnableFn, }; diff --git a/src/bundle/toolbar/custom/ToolbarLinkPopup.tsx b/src/bundle/toolbar/custom/ToolbarLinkPopup.tsx deleted file mode 100644 index 53a1f8df..00000000 --- a/src/bundle/toolbar/custom/ToolbarLinkPopup.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React, {RefObject, useCallback} from 'react'; - -import {Popup, PopupPlacement} from '@gravity-ui/uikit'; - -import {LinkForm, LinkFormSubmitParams} from '../../../forms/LinkForm'; -import type {ToolbarBaseProps} from '../../../toolbar'; - -const placement: PopupPlacement = ['bottom-start', 'top-start', 'bottom-end', 'top-end']; - -export type ToolbarLinkPopupProps = Omit, 'editor'> & { - onSubmit(url: string, text: string): void; - formInitialText?: string; - formReadOnlyText?: boolean; - hide: () => void; - anchorRef: RefObject; -}; - -export const ToolbarLinkPopup: React.FC = ({ - className, - focus, - onClick, - - anchorRef, - formInitialText, - formReadOnlyText, - onSubmit, - hide, -}) => { - const handleCancel = useCallback(() => { - hide(); - focus(); - }, [focus, hide]); - - const handleSubmit = useCallback( - ({url, text}: LinkFormSubmitParams) => { - hide(); - focus(); - onSubmit(url, text); - onClick?.('addLink'); - }, - [focus, hide, onClick, onSubmit], - ); - - return ( - - - - ); -}; diff --git a/src/bundle/toolbar/markup/MToolbarColors.tsx b/src/bundle/toolbar/markup/MToolbarColors.tsx index 751db15c..85c2580d 100644 --- a/src/bundle/toolbar/markup/MToolbarColors.tsx +++ b/src/bundle/toolbar/markup/MToolbarColors.tsx @@ -1,7 +1,7 @@ import React from 'react'; +import type {CodeEditor} from '../../../markup'; import {colorify} from '../../../markup/commands'; -import type {CodeEditor} from '../../../markup/editor'; import {ToolbarBaseProps} from '../../../toolbar'; import {ToolbarColors} from '../custom/ToolbarColors'; @@ -17,7 +17,7 @@ export const MToolbarColors: React.FC = ({ { - colorify(editor.cm, color); + colorify(color)(editor.cm); }} className={className} focus={focus} diff --git a/src/bundle/toolbar/markup/MToolbarFilePopup.tsx b/src/bundle/toolbar/markup/MToolbarFilePopup.tsx index 470a758b..282c04c4 100644 --- a/src/bundle/toolbar/markup/MToolbarFilePopup.tsx +++ b/src/bundle/toolbar/markup/MToolbarFilePopup.tsx @@ -29,17 +29,16 @@ export const MToolbarFilePopup: React.FC = ({ onClick={onClick} anchorRef={anchorRef} className={className} - onSubmit={(fileParams) => insertFiles(editor.cm, [fileParams])} + onSubmit={(fileParams) => insertFiles([fileParams])(editor.cm)} uploadHandler={uploadHandler} onSuccessUpload={(res) => { insertFiles( - editor.cm, res.success.map(({result, file}) => ({ src: result.url, name: result.name ?? file.name, type: result.type ?? file.type, })), - ); + )(editor.cm); }} /> ); diff --git a/src/bundle/toolbar/markup/MToolbarImagePopup.tsx b/src/bundle/toolbar/markup/MToolbarImagePopup.tsx index b0df8de4..60188b1d 100644 --- a/src/bundle/toolbar/markup/MToolbarImagePopup.tsx +++ b/src/bundle/toolbar/markup/MToolbarImagePopup.tsx @@ -2,12 +2,11 @@ import React, {RefObject} from 'react'; import isNumber from 'is-number'; +import {IMG_MAX_HEIGHT, getImageDimensions} from '../../../markup'; import {ImageItem, insertImages} from '../../../markup/commands'; import type {CodeEditor} from '../../../markup/editor'; import type {ToolbarBaseProps} from '../../../toolbar'; import type {UploadSuccessItem} from '../../../utils/upload'; -import {IMG_MAX_HEIGHT} from '../../cm-upload/const'; -import {getImageDimensions} from '../../cm-upload/utils'; import {ToolbarImagePopup} from '../custom/ToolbarImagePopup'; import {useMarkupToolbarContext} from './context'; @@ -39,7 +38,7 @@ export const MToolbarImagePopup: React.FC = ({ onClick={onClick} className={className} onSubmit={({url, name, alt, width, height}) => { - insertImages(editor.cm, [ + insertImages([ { url, alt, @@ -47,7 +46,7 @@ export const MToolbarImagePopup: React.FC = ({ width: isNumber(width) ? String(width) : '', height: isNumber(height) ? String(height) : '', }, - ]); + ])(editor.cm); }} uploadImages={uploadHandler} onSuccessUpload={async (res) => { @@ -55,7 +54,7 @@ export const MToolbarImagePopup: React.FC = ({ res.success, Boolean(needToSetDimensionsForUploadedImages), ); - insertImages(editor.cm, images); + insertImages(images)(editor.cm); }} /> ); diff --git a/src/extensions/behavior/CodeMirrorView/YCThemeWatcher/index.ts b/src/extensions/behavior/CodeMirrorView/YCThemeWatcher/index.ts deleted file mode 100644 index a49f6739..00000000 --- a/src/extensions/behavior/CodeMirrorView/YCThemeWatcher/index.ts +++ /dev/null @@ -1,64 +0,0 @@ -import {useThemeType, useThemeValue} from '@gravity-ui/uikit'; -import type {RealTheme, ThemeType} from '@gravity-ui/uikit/build/esm/components/theme/types'; -import {Plugin} from 'prosemirror-state'; - -import {EventEmitter, Receiver} from '../../../../utils/event-emitter'; -import {getReactRendererFromState} from '../../ReactRenderer'; - -export type WatcherEventMap = { - 'change-type': ThemeType; - 'change-theme': RealTheme; -}; - -export interface WatcherReceiver extends Receiver { - readonly type: ThemeType; - readonly theme: RealTheme; -} - -export class YCThemeStore extends EventEmitter implements WatcherReceiver { - #type: ThemeType = 'light'; - #theme: RealTheme = 'light'; - - get type(): ThemeType { - return this.#type; - } - - get theme(): RealTheme { - return this.#theme; - } - - setType(type: ThemeType): void { - if (this.#type !== type) { - this.#type = type; - this.emit('change-type', type); - } - } - - setTheme(theme: RealTheme): void { - if (this.#theme !== theme) { - this.#theme = theme; - this.emit('change-theme', theme); - } - } -} - -export const ycThemeWatcherPlugin = (themeStore: Pick) => - new Plugin({ - view: (view) => { - const item = getReactRendererFromState(view.state).createItem( - 'yc-theme-watcher', - () => { - const type = useThemeType(); - const theme = useThemeValue(); - themeStore.setType(type); - themeStore.setTheme(theme); - return null; - }, - ); - return { - destroy() { - item.remove(); - }, - }; - }, - }); diff --git a/src/extensions/behavior/CodeMirrorView/cm/CodeBlockView.scss b/src/extensions/behavior/CodeMirrorView/cm/CodeBlockView.scss deleted file mode 100644 index fb98cc5d..00000000 --- a/src/extensions/behavior/CodeMirrorView/cm/CodeBlockView.scss +++ /dev/null @@ -1,8 +0,0 @@ -.ProseMirror .CodeMirror { - height: auto; - margin-bottom: 15px; - padding: 16px; - - border-radius: 10px; - background: var(--g-color-base-misc-light); -} diff --git a/src/extensions/behavior/CodeMirrorView/cm/CodeBlockView.ts b/src/extensions/behavior/CodeMirrorView/cm/CodeBlockView.ts deleted file mode 100644 index f16c53a5..00000000 --- a/src/extensions/behavior/CodeMirrorView/cm/CodeBlockView.ts +++ /dev/null @@ -1,303 +0,0 @@ -/* eslint-disable import/no-extraneous-dependencies */ -import type {ThemeType} from '@gravity-ui/uikit/build/esm/components/theme/types'; -import CodeMirror from 'codemirror'; -import 'codemirror/addon/display/placeholder'; -import {exitCode, selectAll} from 'prosemirror-commands'; -import {redo, undo} from 'prosemirror-history'; -import {Fragment, Node} from 'prosemirror-model'; -import {Selection, TextSelection} from 'prosemirror-state'; -import {EditorView, NodeView, NodeViewConstructor} from 'prosemirror-view'; - -import type {CodeMirrorViewOptions} from '..'; -import {resetCodeblock} from '../../../../extensions/markdown'; -import {isFunction} from '../../../../lodash'; -import {isMac} from '../../../../utils/platform'; -import {createFakeParagraph, findFakeParaPosForTextSelection} from '../../Selection'; -import type {WatcherReceiver} from '../YCThemeWatcher'; -import {langAttr} from '../const'; - -import {getModeMIME, modeMimes} from './codemodes'; - -import 'codemirror/theme/nord.css'; -import './CodeBlockView.scss'; // eslint-disable-line import/order - -const yc2cmThemeMap: Record = { - dark: 'nord', - light: 'default', -}; - -type ViewOptions = CodeMirrorViewOptions & { - themeWatcher: WatcherReceiver; -}; - -export class CodeBlockView implements NodeView { - static creator = - (opts: ViewOptions): NodeViewConstructor => - (node, view, getPos) => { - return new this(node, view, getPos, opts); - }; - - dom: HTMLElement; - - private node: Node; - private readonly view: EditorView; - private readonly getPos: () => number | undefined; - - private readonly cm: CodeMirror.Editor; - private updating: boolean; - private incomingChanges: boolean; - - private readonly themeWatcher: WatcherReceiver; - - constructor(node: Node, view: EditorView, getPos: () => number | undefined, opts: ViewOptions) { - // Store for later - this.node = node; - this.view = view; - this.getPos = getPos; - this.themeWatcher = opts.themeWatcher; - - const {codeBlockPlaceholder} = opts; - - // Create a CodeMirror instance - this.cm = CodeMirror( - // @ts-expect-error bad types: 'null' is not assignable to... - null, - { - value: this.node.textContent, - lineNumbers: false, - extraKeys: this.codeMirrorKeymap(opts), - mode: getModeMIME(node.attrs[langAttr]), - placeholder: isFunction(codeBlockPlaceholder) - ? codeBlockPlaceholder() - : codeBlockPlaceholder, - theme: yc2cmThemeMap[this.themeWatcher.type], - }, - ); - - // The editor's outer node is our DOM representation - this.dom = this.cm.getWrapperElement(); - // CodeMirror needs to be in the DOM to properly initialize, so - // schedule it to update itself - setTimeout(() => this.cm.refresh(), 20); - - this.incomingChanges = false; - // This flag is used to avoid an update loop between the outer and - // inner editor - this.updating = false; - - // Track whether changes are have been made but not yet propagated - this.cm.on('beforeChange', () => { - this.incomingChanges = true; - }); - this.cm.on('changes', () => { - if (!this.updating) { - this.valueChanged(); - this.forwardSelection(); - } - this.incomingChanges = false; - }); - // Propagate updates from the code editor to ProseMirror - this.cm.on('cursorActivity', () => { - if (!this.updating && !this.incomingChanges) this.forwardSelection(); - }); - this.cm.on('focus', () => this.forwardSelection()); - - this.themeWatcher.on('change-type', this.updateCmTheme); - } - - update(node: Node) { - if (node.type !== this.node.type) return false; - this.node = node; - const change = computeChange(this.cm.getValue(), node.textContent); - if (change) { - this.updating = true; - this.cm.replaceRange( - change.text, - this.cm.posFromIndex(change.from), - this.cm.posFromIndex(change.to), - ); - this.updating = false; - } - - this.cm.setOption('mode', modeMimes[node.attrs['data-language']]); - - return true; - - // TODO: update editor's mode if lang attr has been changed - } - - destroy() { - this.themeWatcher.off('change-type', this.updateCmTheme); - } - - selectNode() { - this.cm.focus(); - } - - setSelection(anchor: number, head: number) { - this.cm.focus(); - this.updating = true; - this.cm.setSelection(this.cm.posFromIndex(anchor), this.cm.posFromIndex(head)); - this.updating = false; - } - - stopEvent() { - return true; - } - - private updateCmTheme = (ycThemeType: ThemeType) => { - const cmTheme = yc2cmThemeMap[ycThemeType]; - if (this.cm.getOption('theme') !== cmTheme) { - this.cm.setOption('theme', cmTheme); - this.cm.refresh(); - } - }; - - private forwardSelection() { - if (!this.cm.hasFocus()) return; - const {state} = this.view; - const selection = this.asProseMirrorSelection(state.doc); - if (!selection.eq(state.selection)) { - this.view.dispatch(state.tr.setSelection(selection)); - } - } - - private asProseMirrorSelection(doc: Node) { - const offset = this.getPos()! + 1; - const anchor = this.cm.indexFromPos(this.cm.getCursor('anchor')) + offset; - const head = this.cm.indexFromPos(this.cm.getCursor('head')) + offset; - return TextSelection.create(doc, anchor, head); - } - - private valueChanged() { - const pos = this.getPos(); - const change = computeChange(this.node.textContent, this.cm.getValue()); - if (change && pos !== undefined) { - const start = pos + 1; - const {state} = this.view; - const tr = state.tr.replaceWith( - start + change.from, - start + change.to, - change.text ? state.schema.text(change.text) : Fragment.empty, - ); - this.view.dispatch(tr); - } - } - - private codeMirrorKeymap(opts?: CodeMirrorViewOptions) { - const view = this.view; - const mod = isMac() ? 'Cmd' : 'Ctrl'; - - const keymap: CodeMirror.KeyMap = { - Up: () => this.maybeEscape('line', -1), - Left: () => this.maybeEscape('char', -1), - Down: () => this.maybeEscape('line', 1), - Right: () => this.maybeEscape('char', 1), - 'Shift-Enter': () => { - if (exitCode(view.state, view.dispatch)) view.focus(); - }, - Backspace: () => { - if (resetCodeblock(view.state, view.dispatch, view)) return view.focus(); - else return CodeMirror.Pass; - }, - [`${mod}-Z`]: () => undo(view.state, view.dispatch), - [`Shift-${mod}-Z`]: () => redo(view.state, view.dispatch), - [`${mod}-Y`]: () => redo(view.state, view.dispatch), - [`${mod}-A`]: () => this.onAllSelect(), - }; - - if (opts) { - ( - [ - [opts.onCancel, 'Esc'], - [opts.onSubmit, `${mod}-Enter`], - ] as const - ).forEach(([handler, key]) => { - if (handler) { - keymap[key] = () => { - if (!handler()) return CodeMirror.Pass; - return; - }; - } - }); - } - - return CodeMirror.normalizeKeyMap(keymap); - } - - private onAllSelect() { - const sels = this.cm.listSelections(); - if (sels.length !== 1) { - return CodeMirror.Pass; - } - - const [sel] = sels; - const from = sel.from(); - const to = sel.to(); - - const isDocStart = from.line === this.cm.firstLine() && from.ch === 0; - const isDocEnd = - to.line === this.cm.lastLine() && to.ch === this.cm.getLine(this.cm.lastLine()).length; - - const isAllSelection = isDocStart && isDocEnd; - - if (!isAllSelection) { - return CodeMirror.Pass; - } - - // select all prosemirror doc, when all codemirror doc already selected - this.cm.setSelection(from); - selectAll(this.view.state, this.view.dispatch); - this.view.focus(); - return; - } - - private maybeEscape(unit: 'line' | 'char', dir: -1 | 1) { - const pos = this.cm.getCursor(); - if ( - this.cm.somethingSelected() || - pos.line !== (dir < 0 ? this.cm.firstLine() : this.cm.lastLine()) || - (unit === 'char' && pos.ch !== (dir < 0 ? 0 : this.cm.getLine(pos.line).length)) - ) - return CodeMirror.Pass; - - const direction = dir < 0 ? 'before' : 'after'; - const $pos = findFakeParaPosForTextSelection( - this.view.state.selection as TextSelection, - direction, - ); - if ($pos) { - const {tr} = this.view.state; - createFakeParagraph(tr, $pos, direction); - this.view.dispatch(tr.scrollIntoView()); - } else { - const pos = this.getPos(); - if (pos !== undefined) { - const targetPos = pos + (dir < 0 ? 0 : this.node.nodeSize); - const selection = Selection.near(this.view.state.doc.resolve(targetPos), dir); - this.view.dispatch(this.view.state.tr.setSelection(selection).scrollIntoView()); - } - } - - this.view.focus(); - - return null; - } -} - -function computeChange(oldVal: string, newVal: string) { - if (oldVal === newVal) return null; - let start = 0, - oldEnd = oldVal.length, - newEnd = newVal.length; - while (start < oldEnd && oldVal.charCodeAt(start) === newVal.charCodeAt(start)) ++start; - while ( - oldEnd > start && - newEnd > start && - oldVal.charCodeAt(oldEnd - 1) === newVal.charCodeAt(newEnd - 1) - ) { - oldEnd--; - newEnd--; - } - return {from: start, to: oldEnd, text: newVal.slice(start, newEnd)}; -} diff --git a/src/extensions/behavior/CodeMirrorView/cm/codemodes.ts b/src/extensions/behavior/CodeMirrorView/cm/codemodes.ts deleted file mode 100644 index 26e33d9b..00000000 --- a/src/extensions/behavior/CodeMirrorView/cm/codemodes.ts +++ /dev/null @@ -1,93 +0,0 @@ -import 'codemirror/mode/clike/clike'; -import 'codemirror/mode/css/css'; -import 'codemirror/mode/diff/diff'; -import 'codemirror/mode/go/go'; -import 'codemirror/mode/htmlmixed/htmlmixed'; -import 'codemirror/mode/javascript/javascript'; -import 'codemirror/mode/jsx/jsx'; -import 'codemirror/mode/markdown/markdown'; -import 'codemirror/mode/python/python'; -import 'codemirror/mode/rust/rust'; -import 'codemirror/mode/shell/shell'; -import 'codemirror/mode/sql/sql'; - -export const modeMimes: Record = { - text: 'text/text', - - // 'codemirror/mode/diff/diff' - diff: 'text/x-diff', - - // codemirror/mode/markdown/markdown - md: 'text/markdown', - markdown: 'text/markdown', - - // codemirror/mode/javascript/javascript - json: 'application/json', - js: 'text/javascript', - ts: 'text/typescript', - javascript: 'text/javascript', - typescript: 'text/typescript', - - // // codemirror/mode/jsx/jsx - jsx: 'text/jsx', - tsx: 'text/typescript-jsx', - - // codemirror/mode/python/python - python: 'text/x-python', - - // codemirror/mode/css/css - css: 'text/css', - scss: 'text/x-scss', - less: 'text/x-less', - gss: 'text/x-gss', - - // codemirror/mode/clike/clike - c: 'text/x-csrc', - cpp: 'text/x-c++src', - 'c++': 'text/x-c++src', - java: 'text/x-java', - cs: 'text/x-csharp', - 'c#': 'text/x-csharp', - csharp: 'text/x-csharp', - scala: 'text/x-scala', - kotlin: 'text/x-kotlin', - // TODO: add more clike langs support - - // 'codemirror/mode/go/go' - go: 'text/x-go', - - // 'codemirror/mode/rust/rust' - rust: 'text/x-rustsrc', - - // 'codemirror/mode/sql/sql' - sql: 'text/x-sql', - mysql: 'text/x-mysql', - postgresql: 'text/x-pgsql', - pg: 'text/x-pgsql', - - // 'codemirror/mode/shell/shell' - shell: 'application/x-sh', - - // codemirror/mode/htmlmixed/htmlmixed - html: 'text/html', -}; - -export const getUniqueModeNames = () => { - const names: Record = {}; - - for (const modeName in modeMimes) { - if (modeMimes[modeName]) { - const modeMime = modeMimes[modeName]; - - const oldName = names[modeMimes[modeMime]]; - if ((oldName && oldName.length < modeName.length) || !oldName) - names[modeMime] = modeName; - } - } - - return Object.values(names); -}; - -export function getModeMIME(langValue: string) { - return modeMimes[langValue] || 'text/plain'; -} diff --git a/src/extensions/behavior/CodeMirrorView/commands.ts b/src/extensions/behavior/CodeMirrorView/commands.ts deleted file mode 100644 index 211000ea..00000000 --- a/src/extensions/behavior/CodeMirrorView/commands.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {Command, Selection} from 'prosemirror-state'; - -export function arrowHandler(dir: 'left' | 'right' | 'up' | 'down', nodeName: string): Command { - return (state, dispatch, view) => { - if (state.selection.empty && view?.endOfTextblock(dir)) { - const side = dir === 'left' || dir === 'up' ? -1 : 1, - $head = state.selection.$head; - const nextPos = Selection.near( - state.doc.resolve(side > 0 ? $head.after() : $head.before()), - side, - ); - if (nextPos.$head && nextPos.$head.parent.type.name === nodeName) { - dispatch?.(state.tr.setSelection(nextPos)); - return true; - } - } - return false; - }; -} diff --git a/src/extensions/behavior/CodeMirrorView/const.ts b/src/extensions/behavior/CodeMirrorView/const.ts deleted file mode 100644 index 3ab70de9..00000000 --- a/src/extensions/behavior/CodeMirrorView/const.ts +++ /dev/null @@ -1,3 +0,0 @@ -// TODO: import it -export const codeBlockNodeName = 'code_block'; -export const langAttr = 'data-language'; diff --git a/src/extensions/behavior/CodeMirrorView/index.ts b/src/extensions/behavior/CodeMirrorView/index.ts deleted file mode 100644 index 691f8bc8..00000000 --- a/src/extensions/behavior/CodeMirrorView/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -import {keydownHandler} from 'prosemirror-keymap'; -import {Plugin} from 'prosemirror-state'; - -import {ExtensionWithOptions} from '../../../core'; -import {capitalize} from '../../../lodash'; - -import {codeLangSelectTooltipViewCreator} from './TooltipPlugin'; -import {YCThemeStore, ycThemeWatcherPlugin} from './YCThemeWatcher'; -import {CodeBlockView} from './cm/CodeBlockView'; -import {getUniqueModeNames} from './cm/codemodes'; -import {arrowHandler} from './commands'; -import {codeBlockNodeName} from './const'; - -export type CodeMirrorViewOptions = { - onCancel?: () => boolean; - onSubmit?: () => boolean; - codeBlockPlaceholder?: CodeMirror.EditorConfiguration['placeholder'] | (() => string); -}; - -export const CodeMirrorViewExtension: ExtensionWithOptions = ( - builder, - options, -) => { - builder.addPlugin(() => { - const watcherEmitter = new YCThemeStore(); - const langItems = getUniqueModeNames().map((name) => ({ - value: name, - title: capitalize(name), - })); - - return [ - ycThemeWatcherPlugin(watcherEmitter), - new Plugin({ - props: { - nodeViews: { - [codeBlockNodeName]: CodeBlockView.creator({ - ...options, - themeWatcher: watcherEmitter, - }), - }, - // same as keymap({}) - handleKeyDown: keydownHandler({ - ArrowLeft: arrowHandler('left', codeBlockNodeName), - ArrowRight: arrowHandler('right', codeBlockNodeName), - ArrowUp: arrowHandler('up', codeBlockNodeName), - ArrowDown: arrowHandler('down', codeBlockNodeName), - }), - }, - view: (view) => codeLangSelectTooltipViewCreator(view, langItems), - }), - ]; - }); -}; diff --git a/src/extensions/markdown/CodeBlock/CodeBlockHighlight/CodeBlockHighlight.ts b/src/extensions/markdown/CodeBlock/CodeBlockHighlight/CodeBlockHighlight.ts index ebcff4b1..6b4f034a 100644 --- a/src/extensions/markdown/CodeBlock/CodeBlockHighlight/CodeBlockHighlight.ts +++ b/src/extensions/markdown/CodeBlock/CodeBlockHighlight/CodeBlockHighlight.ts @@ -1,8 +1,6 @@ import type {Options} from '@diplodoc/transform'; // importing only type, because lowlight and highlight.js is optional deps -// eslint-disable-next-line import/no-extraneous-dependencies import type HLJS from 'highlight.js/lib/core'; -// eslint-disable-next-line import/no-extraneous-dependencies import type {createLowlight} from 'lowlight'; import type {Root} from 'lowlight/lib/core'; import {Node} from 'prosemirror-model'; @@ -11,12 +9,12 @@ import {Step} from 'prosemirror-transform'; import {findChildrenByType} from 'prosemirror-utils'; import {Decoration, DecorationSet} from 'prosemirror-view'; -import {ExtensionAuto} from '../../../../core'; +import type {ExtensionAuto} from '../../../../core'; import {capitalize} from '../../../../lodash'; import {logger} from '../../../../logger'; -// TODO: check cycle imports -import {codeLangSelectTooltipViewCreator} from '../../../behavior/CodeMirrorView/TooltipPlugin'; -import {codeBlockLangAttr, codeBlockNodeName, codeBlockType} from '../CodeBlockSpecs/index'; +import {codeBlockLangAttr, codeBlockNodeName, codeBlockType} from '../CodeBlockSpecs'; + +import {codeLangSelectTooltipViewCreator} from './TooltipPlugin'; export type HighlightLangMap = Options['highlightLangs']; diff --git a/src/extensions/behavior/CodeMirrorView/TooltipPlugin/TooltipView.scss b/src/extensions/markdown/CodeBlock/CodeBlockHighlight/TooltipPlugin/TooltipView.scss similarity index 100% rename from src/extensions/behavior/CodeMirrorView/TooltipPlugin/TooltipView.scss rename to src/extensions/markdown/CodeBlock/CodeBlockHighlight/TooltipPlugin/TooltipView.scss diff --git a/src/extensions/behavior/CodeMirrorView/TooltipPlugin/index.tsx b/src/extensions/markdown/CodeBlock/CodeBlockHighlight/TooltipPlugin/index.tsx similarity index 86% rename from src/extensions/behavior/CodeMirrorView/TooltipPlugin/index.tsx rename to src/extensions/markdown/CodeBlock/CodeBlockHighlight/TooltipPlugin/index.tsx index 532c814e..3ef2ba9b 100644 --- a/src/extensions/behavior/CodeMirrorView/TooltipPlugin/index.tsx +++ b/src/extensions/markdown/CodeBlock/CodeBlockHighlight/TooltipPlugin/index.tsx @@ -5,13 +5,12 @@ import {Select, SelectOption} from '@gravity-ui/uikit'; import {Node} from 'prosemirror-model'; import {EditorView} from 'prosemirror-view'; -import {codeBlockType} from '../../../../extensions/markdown'; -import {i18n} from '../../../../i18n/codeblock'; -import {i18n as i18nPlaceholder} from '../../../../i18n/placeholder'; -import {BaseTooltipPluginView} from '../../../../plugins/BaseTooltip'; -import {Toolbar, ToolbarDataType} from '../../../../toolbar'; -import {removeNode} from '../../../../utils/remove-node'; -import {langAttr} from '../const'; +import {i18n} from '../../../../../i18n/codeblock'; +import {i18n as i18nPlaceholder} from '../../../../../i18n/placeholder'; +import {BaseTooltipPluginView} from '../../../../../plugins/BaseTooltip'; +import {Toolbar, ToolbarDataType} from '../../../../../toolbar'; +import {removeNode} from '../../../../../utils/remove-node'; +import {CodeBlockNodeAttr, codeBlockType} from '../../CodeBlockSpecs'; import './TooltipView.scss'; @@ -24,7 +23,7 @@ type CodeMenuProps = { }; const CodeMenu: React.FC = ({view, pos, node, selectItems, mapping}) => { - const lang = node.attrs[langAttr]; + const lang = node.attrs[CodeBlockNodeAttr.Lang]; const value = mapping[lang] ?? lang; const handleClick = (type: string) => { @@ -33,7 +32,7 @@ const CodeMenu: React.FC = ({view, pos, node, selectItems, mappin view.dispatch( view.state.tr.setNodeMarkup(pos, null, { - [langAttr]: type, + [CodeBlockNodeAttr.Lang]: type, }), ); }; diff --git a/src/index.ts b/src/index.ts index ccc393a5..3fc8480e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,10 @@ export * from './extensions/specs'; export * from './forms'; export * from './view'; export * from './utils'; -export * from './markup'; + +export {ReactRendererFacet, getImageDimensions} from './markup'; +export * as MarkupCommands from './markup/commands'; +export * as MarkupHelpers from './markup/commands/helpers'; export {Lang, configure} from './configure'; diff --git a/src/markup/active.ts b/src/markup/active.ts deleted file mode 100644 index 29d60eb9..00000000 --- a/src/markup/active.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type CodeMirror from 'codemirror'; - -export const isBoldActive = isActive('strong'); -export const isItalicActive = isActive('em'); - -export const isH1Active = isActive('header-1'); -export const isH2Active = isActive('header-2'); -export const isH3Active = isActive('header-3'); -export const isH4Active = isActive('header-4'); -export const isH5Active = isActive('header-5'); -export const isH6Active = isActive('header-6'); - -function isActive(type: string) { - return (cm: CodeMirror.Editor): boolean => { - const token = cm.getTokenTypeAt(cm.getCursor('start')) ?? ''; - return token.split(' ').includes(type); - }; -} diff --git a/src/markup/codemirror/create.ts b/src/markup/codemirror/create.ts new file mode 100644 index 00000000..f93b5e1a --- /dev/null +++ b/src/markup/codemirror/create.ts @@ -0,0 +1,140 @@ +import {autocompletion} from '@codemirror/autocomplete'; +import {defaultKeymap, history, historyKeymap, indentWithTab} from '@codemirror/commands'; +import {syntaxHighlighting} from '@codemirror/language'; +import type {Extension, StateCommand} from '@codemirror/state'; +import {EditorView, EditorViewConfig, keymap, placeholder} from '@codemirror/view'; + +import {ActionName} from '../../bundle/config/action-names'; +import {ReactRenderStorage} from '../../extensions'; +import {logger} from '../../logger'; +import {Action as A, formatter as f} from '../../shortcuts'; +import { + insertLink, + toH1, + toH2, + toH3, + toH4, + toH5, + toH6, + toggleBold, + toggleItalic, + toggleStrikethrough, + toggleUnderline, + wrapToCodeBlock, + wrapToInlineCode, + wrapToYfmCut, + wrapToYfmNote, +} from '../commands'; + +import {FileUploadHandler, FileUploadHandlerFacet} from './files-upload-facet'; +import {gravityHighlightStyle, gravityTheme} from './gravity'; +import {PairingCharactersExtension} from './pairing-chars'; +import {ReactRendererFacet} from './react-facet'; +import {yfmLang} from './yfm'; + +export type CreateCodemirrorParams = { + doc: EditorViewConfig['doc']; + placeholderText: string; + onCancel: () => void; + onSubmit: () => void; + onChange: () => void; + onDocChange: () => void; + onScroll: (event: Event) => void; + reactRenderer: ReactRenderStorage; + uploadHandler?: FileUploadHandler; + needImgDimms?: boolean; +}; + +export function createCodemirror(params: CreateCodemirrorParams) { + const { + doc, + placeholderText, + reactRenderer, + onCancel, + onScroll, + onSubmit, + onChange, + onDocChange, + } = params; + + const extensions: Extension[] = [ + gravityTheme, + placeholder(placeholderText), + history(), + syntaxHighlighting(gravityHighlightStyle), + keymap.of([ + {key: f.toCM(A.Bold)!, run: withLogger(ActionName.bold, toggleBold)}, + {key: f.toCM(A.Italic)!, run: withLogger(ActionName.italic, toggleItalic)}, + {key: f.toCM(A.Strike)!, run: withLogger(ActionName.strike, toggleStrikethrough)}, + {key: f.toCM(A.Underline)!, run: withLogger(ActionName.underline, toggleUnderline)}, + {key: f.toCM(A.Link)!, run: withLogger(ActionName.link, insertLink)}, + {key: f.toCM(A.Heading1)!, run: withLogger(ActionName.heading1, toH1)}, + {key: f.toCM(A.Heading2)!, run: withLogger(ActionName.heading2, toH2)}, + {key: f.toCM(A.Heading3)!, run: withLogger(ActionName.heading3, toH3)}, + {key: f.toCM(A.Heading4)!, run: withLogger(ActionName.heading4, toH4)}, + {key: f.toCM(A.Heading5)!, run: withLogger(ActionName.heading5, toH5)}, + {key: f.toCM(A.Heading6)!, run: withLogger(ActionName.heading6, toH6)}, + {key: f.toCM(A.Code)!, run: withLogger(ActionName.code_inline, wrapToInlineCode)}, + {key: f.toCM(A.CodeBlock)!, run: withLogger(ActionName.code_block, wrapToCodeBlock)}, + {key: f.toCM(A.Cut)!, run: withLogger(ActionName.yfm_cut, wrapToYfmCut)}, + {key: f.toCM(A.Note)!, run: withLogger(ActionName.yfm_note, wrapToYfmNote)}, + { + key: f.toCM(A.Cancel)!, + preventDefault: true, + run: () => { + onCancel(); + return true; + }, + }, + { + key: f.toCM(A.Submit)!, + preventDefault: true, + run: () => { + onSubmit(); + return true; + }, + }, + indentWithTab, + ...defaultKeymap, + ...historyKeymap, + ]), + autocompletion(), + yfmLang(), + ReactRendererFacet.of(reactRenderer), + PairingCharactersExtension, + EditorView.lineWrapping, + EditorView.contentAttributes.of({spellcheck: 'true'}), + EditorView.domEventHandlers({ + scroll(event) { + onScroll(event); + }, + }), + ]; + if (params.uploadHandler) { + extensions.push( + FileUploadHandlerFacet.of({ + fn: params.uploadHandler, + imgWithDimms: params.needImgDimms, + }), + ); + } + + return new EditorView({ + doc, + extensions, + dispatchTransactions: (trs, view) => { + view.update(trs); + onChange(); + if (trs.some((tr) => tr.docChanged)) { + onDocChange(); + } + }, + }); +} + +export function withLogger(action: string, command: StateCommand): StateCommand { + return (...args) => { + logger.action({mode: 'markup', source: 'keymap', action}); + return command(...args); + }; +} diff --git a/src/markup/codemirror/files-upload-facet.ts b/src/markup/codemirror/files-upload-facet.ts new file mode 100644 index 00000000..5b1e074a --- /dev/null +++ b/src/markup/codemirror/files-upload-facet.ts @@ -0,0 +1,16 @@ +import {Facet} from '@codemirror/state'; + +import type {FileUploadHandler} from '../../utils/upload'; + +import {FilesUploadPlugin} from './files-upload-plugin'; + +export type {FileUploadHandler}; + +export const FileUploadHandlerFacet = Facet.define< + {fn: FileUploadHandler; imgWithDimms?: boolean}, + {fn: FileUploadHandler; imgWithDimms?: boolean} +>({ + enables: FilesUploadPlugin.extension, + combine: (value) => value[0], + static: true, +}); diff --git a/src/markup/codemirror/files-upload-plugin/const.ts b/src/markup/codemirror/files-upload-plugin/const.ts new file mode 100644 index 00000000..c17182e7 --- /dev/null +++ b/src/markup/codemirror/files-upload-plugin/const.ts @@ -0,0 +1,2 @@ +export const IMG_MAX_HEIGHT = 600; // px +export const SUCCESS_UPLOAD_REMOVE_TIMEOUT = 1000; // 1 sec diff --git a/src/markup/codemirror/files-upload-plugin/effects.ts b/src/markup/codemirror/files-upload-plugin/effects.ts new file mode 100644 index 00000000..4ae453ae --- /dev/null +++ b/src/markup/codemirror/files-upload-plugin/effects.ts @@ -0,0 +1,4 @@ +import {StateEffect} from '@codemirror/state'; + +export const AddUploadWidgetEffect = StateEffect.define<{files: ArrayLike; pos: number}>(); +export const RemoveUploadWidgetEffect = StateEffect.define<{id: string; markup?: string}>(); diff --git a/src/markup/codemirror/files-upload-plugin/index.ts b/src/markup/codemirror/files-upload-plugin/index.ts new file mode 100644 index 00000000..8bbb2381 --- /dev/null +++ b/src/markup/codemirror/files-upload-plugin/index.ts @@ -0,0 +1,3 @@ +export {FilesUploadPlugin} from './plugin'; +export {getImageDimensions} from './utils'; +export {IMG_MAX_HEIGHT} from './const'; diff --git a/src/markup/codemirror/files-upload-plugin/plugin.ts b/src/markup/codemirror/files-upload-plugin/plugin.ts new file mode 100644 index 00000000..794dfde0 --- /dev/null +++ b/src/markup/codemirror/files-upload-plugin/plugin.ts @@ -0,0 +1,264 @@ +import type {ChangeSpec} from '@codemirror/state'; +import { + Decoration, + DecorationSet, + EditorView, + PluginValue, + ViewPlugin, + ViewUpdate, + WidgetType, +} from '@codemirror/view'; + +import type {RendererItem} from '../../../extensions'; +import type {FileUploadHandler, FileUploadResult} from '../../../utils/upload'; +import {FileUploadHandlerFacet} from '../files-upload-facet'; +import {ReactRendererFacet} from '../react-facet'; + +import {IMG_MAX_HEIGHT, SUCCESS_UPLOAD_REMOVE_TIMEOUT} from './const'; +import {AddUploadWidgetEffect, RemoveUploadWidgetEffect} from './effects'; +import {getImageDimensions, getTransferFiles, isImageFile, uniqueId} from './utils'; +import {renderWidget} from './widget'; + +class FileUploadWidget extends WidgetType { + readonly selfId: string; + + private presenter?: FileUploadPresenter; + + private renderer: RendererItem | null = null; + private props: object = {}; + + constructor(id: string) { + super(); + this.selfId = id; + } + + toDOM(view: EditorView): HTMLElement { + const dom = document.createElement('div'); + dom.className = 'cm-file-upload-widget'; + dom.style.display = 'inline-block'; + this.renderer?.remove(); + this.createRenderItem(dom, view); + return dom; + } + + updateDOM(dom: HTMLElement, view: EditorView): boolean { + this.renderer?.remove(); + this.createRenderItem(dom, view); + return true; + } + + destroy(dom: HTMLElement): void { + super.destroy(dom); + this.renderer?.remove(); + this.presenter?.cancel(); + this.presenter = undefined; + } + + setPresenter(presenter: FileUploadPresenter) { + this.presenter = presenter; + } + + render(props: object) { + Object.assign(this.props, props); + this.renderer?.rerender(); + } + + private createRenderItem(dom: HTMLElement, view: EditorView) { + this.renderer = view.state + .facet(ReactRendererFacet) + .createItem('cm-file-upload-widget', () => renderWidget(dom, {...(this.props as any)})); + } +} + +class FileUploadPresenter { + readonly widget: FileUploadWidget; + + private readonly file: File; + private readonly view: Pick; + private readonly uploader: FileUploadHandler; + private readonly needDimmensionsForImages: boolean; + + private state: 'initial' | 'uploading' | 'success' | 'error' | 'canceled' = 'initial'; + + constructor(params: { + file: File; + widget: FileUploadWidget; + uploader: FileUploadHandler; + view: Pick; + needDimmensionsForImages: boolean; + }) { + this.file = params.file; + this.view = params.view; + this.widget = params.widget; + this.uploader = params.uploader; + this.needDimmensionsForImages = params.needDimmensionsForImages; + this.widget.setPresenter(this); + this.run(); + } + + cancel() { + this.state = 'canceled'; + } + + private run() { + this.state = 'uploading'; + this.widget.render({ + status: 'uploading', + fileName: this.file.name, + fileType: isImageFile(this.file) ? 'image' : 'file', + }); + this.uploader(this.file).then(this.onUploadSuccess, this.onUploadError); + } + + private onUploadSuccess = (res: FileUploadResult) => { + if (this.state === 'canceled') return; + this.state = 'success'; + this.widget.render({ + status: 'success', + fileName: this.file.name, + fileType: isImageFile(this.file) ? 'image' : 'file', + }); + setTimeout(async () => { + const markup = await this.formatFileMarkup(res); + if (this.state === 'canceled') return; + + this.view.dispatch({ + effects: RemoveUploadWidgetEffect.of({ + id: this.widget.selfId, + markup, + }), + }); + }, SUCCESS_UPLOAD_REMOVE_TIMEOUT); + }; + + private onUploadError = (err: unknown) => { + if (this.state === 'canceled') return; + this.state = 'error'; + this.widget.render({ + status: 'error', + fileName: this.file.name, + fileType: isImageFile(this.file) ? 'image' : 'file', + errorText: String(err), + onReUploadClick: () => { + if (this.state === 'error') this.run(); + }, + }); + }; + + private async formatFileMarkup(res: FileUploadResult) { + const fileName = res.name ?? this.file.name ?? ''; + + let markup: string; + if (isImageFile(this.file)) { + if (this.needDimmensionsForImages) { + try { + let {height} = await getImageDimensions(this.file); + height = Math.min(height, IMG_MAX_HEIGHT); + markup = `![${fileName}](${res.url} =x${height})`; + } catch (err) { + markup = `![${fileName}](${res.url})`; + } + } else { + markup = `![${fileName}](${res.url})`; + } + } else { + markup = `{% file src="${res.url}" name="${fileName.replace('"', '')}" %}`; + } + + return markup; + } +} + +export const FilesUploadPlugin = ViewPlugin.fromClass( + class implements PluginValue { + decos: DecorationSet = Decoration.none; + readonly view: EditorView; + + constructor(view: EditorView) { + this.view = view; + } + + update(update: ViewUpdate): void { + this.decos = this.decos.map(update.changes); + const uploadFacet = this.view.state.facet(FileUploadHandlerFacet); + + const changes: ChangeSpec[] = []; + + for (const tr of update.transactions) { + for (const eff of tr.effects) { + if (eff.is(AddUploadWidgetEffect)) { + this.decos = this.decos.update({ + add: Array.from(eff.value.files).map((file, index) => { + const {widget} = new FileUploadPresenter({ + file, + view: this.view, + uploader: uploadFacet.fn, + needDimmensionsForImages: Boolean(uploadFacet.imgWithDimms), + widget: new FileUploadWidget(uniqueId('__file_widget_id')), + }); + return Decoration.widget({ + widget, + side: 1, + __file_widget_id: widget.selfId, + }).range(eff.value.pos + index); + }), + }); + } + if (eff.is(RemoveUploadWidgetEffect)) { + this.decos = this.decos.update({ + filter: (from, to, deco) => { + if (deco.spec.__file_widget_id !== eff.value.id) return true; + if (eff.value.markup) { + changes.push({from, to, insert: eff.value.markup}); + } + return false; + }, + }); + } + } + } + + if (changes.length) { + setTimeout(() => { + this.view.dispatch({changes}); + }); + } + } + }, + { + decorations: (v) => v.decos, + eventHandlers: { + paste(event, view) { + if (!event.clipboardData) return false; + + const files = getTransferFiles(event.clipboardData); + if (!files) return false; + + const {from, to} = view.state.selection.main; + + view.dispatch({ + selection: {anchor: from}, + effects: AddUploadWidgetEffect.of({files, pos: from}), + changes: {from, to, insert: ' '.repeat(files.length)}, + }); + event.preventDefault(); + return true; + }, + drop(event, view) { + if (!event.dataTransfer) return false; + + const files = getTransferFiles(event.dataTransfer); + if (!files) return false; + + const pos = this.view.posAtCoords(event, false); + view.dispatch({ + selection: {anchor: pos}, + effects: AddUploadWidgetEffect.of({files, pos}), + changes: {from: pos, insert: ' '.repeat(files.length)}, + }); + event.preventDefault(); + return true; + }, + }, + }, +); diff --git a/src/bundle/cm-upload/utils.ts b/src/markup/codemirror/files-upload-plugin/utils.ts similarity index 59% rename from src/bundle/cm-upload/utils.ts rename to src/markup/codemirror/files-upload-plugin/utils.ts index 3f48afd1..a09a0a7d 100644 --- a/src/bundle/cm-upload/utils.ts +++ b/src/markup/codemirror/files-upload-plugin/utils.ts @@ -1,3 +1,8 @@ +import {isFilesFromHtml, isFilesOnly} from '../../../utils/clipboard'; + +export {uniqueId} from '../../../lodash'; +export {isImageFile} from '../../../utils/clipboard'; + export async function getImageDimensions(imgFile: File) { const img = await loadImage(imgFile); return {width: img.naturalWidth, height: img.naturalHeight}; @@ -14,3 +19,9 @@ export async function loadImage(imgFile: File) { img.onerror = (_e, _s, _l, _c, error) => reject(error); }); } + +export function getTransferFiles(data: DataTransfer | null): File[] | null { + if (!data) return null; + if (!isFilesOnly(data) && !isFilesFromHtml(data)) return null; + return Array.from(data.files); +} diff --git a/src/markup/codemirror/files-upload-plugin/widget.scss b/src/markup/codemirror/files-upload-plugin/widget.scss new file mode 100644 index 00000000..13bb27e2 --- /dev/null +++ b/src/markup/codemirror/files-upload-plugin/widget.scss @@ -0,0 +1,20 @@ +@use '~@gravity-ui/uikit/styles/mixins.scss'; + +.ye-upload-label { + &__content { + display: flex; + align-items: center; + column-gap: 4px; + } + + &__filename { + display: inline-block; + @include mixins.max-text-width(128px); + } +} + +.cm-file-upload-widget { + & + & { + margin-left: 2px; + } +} diff --git a/src/bundle/cm-upload/FilesUploadWidget.tsx b/src/markup/codemirror/files-upload-plugin/widget.tsx similarity index 53% rename from src/bundle/cm-upload/FilesUploadWidget.tsx rename to src/markup/codemirror/files-upload-plugin/widget.tsx index 8482e835..85e1d863 100644 --- a/src/bundle/cm-upload/FilesUploadWidget.tsx +++ b/src/markup/codemirror/files-upload-plugin/widget.tsx @@ -1,13 +1,20 @@ import React from 'react'; -import {ArrowsRotateRight, Xmark} from '@gravity-ui/icons'; -import {Button, Icon, Label, Spin, Tooltip} from '@gravity-ui/uikit'; -import {createPortal} from 'react-dom'; +import {ArrowsRotateRight} from '@gravity-ui/icons'; +import {Icon, Label, Portal, Spin, Tooltip} from '@gravity-ui/uikit'; -import {cn} from '../../classname'; -import {icons} from '../config/icons'; +import {icons} from '../../../bundle/config/icons'; +import {cn} from '../../../classname'; -import './FilesUploadWidget.scss'; +import './widget.scss'; + +export function renderWidget(container: HTMLElement, props: UploadLabelProps) { + return ( + + + + ); +} export type UploadedFile = { fileName: string; @@ -16,52 +23,17 @@ export type UploadedFile = { errorText?: string; }; -export function renderUploadWidget(container: HTMLElement, props: UploadWidgetProps) { - return () => createPortal(, container); -} - -const cnWidget = cn('upload-widget'); -type UploadWidgetProps = { - files: readonly UploadedFile[]; - onReUploadClick: (file: UploadedFile) => void; - onCloseClick: () => void; -}; -function UploadWidget({files, onReUploadClick, onCloseClick}: UploadWidgetProps) { - if (!files.length) return null; - - const showCloseButton = files.every((f) => f.status === 'error'); - - return ( -
-
- Uploading files: - {files.map((file) => ( - onReUploadClick(file)} - /> - ))} -
- {showCloseButton && ( - - )} -
- ); -} - const cnLabel = cn('upload-label'); -type UploadLabelProps = UploadedFile & { +export type UploadLabelProps = UploadedFile & { onReUploadClick: () => void; }; -function UploadLabel({fileName, fileType, status, errorText, onReUploadClick}: UploadLabelProps) { +export function UploadLabel({ + fileName, + fileType, + status, + errorText, + onReUploadClick, +}: UploadLabelProps) { const icon = fileType === 'image' ? icons.image : icons.file; if (status === 'uploading') { diff --git a/src/markup/codemirror/gravity.ts b/src/markup/codemirror/gravity.ts new file mode 100644 index 00000000..22017e0e --- /dev/null +++ b/src/markup/codemirror/gravity.ts @@ -0,0 +1,51 @@ +import {HighlightStyle, defaultHighlightStyle} from '@codemirror/language'; +import {EditorView} from '@codemirror/view'; +import {tags as t} from '@lezer/highlight'; + +import {customTags as ct} from './yfm'; + +export const gravityHighlightStyle = HighlightStyle.define( + defaultHighlightStyle.specs.concat( + {tag: t.meta, color: 'var(--g-color-text-hint)'}, + {tag: t.link, color: 'var(--g-color-text-link)'}, + {tag: t.url, color: 'var(--g-color-text-link-hover)'}, + {tag: t.contentSeparator, color: 'var(--g-color-text-secondary)'}, + {tag: [t.string, t.deleted], color: 'var(--g-color-text-danger)'}, + {tag: t.escape, color: 'var(--g-color-text-danger-heavy)'}, + {tag: t.typeName, color: 'var(--g-color-text-positive-heavy)'}, + {tag: t.atom, color: 'var(--g-color-text-info-heavy)'}, + {tag: t.labelName, color: 'var(--g-color-text-complementary)'}, + {tag: t.heading, fontWeight: 'bold'}, + // custom tags + {tag: ct.underline, textDecoration: 'underline'}, + {tag: ct.monospace, fontFamily: 'monospace'}, + {tag: ct.marked, color: 'marktext', backgroundColor: 'mark'}, + ), +); + +export const gravityTheme = EditorView.baseTheme({ + '&': { + overflow: 'hidden', + height: '100%', + }, + '&.cm-focused': { + outline: 'none', + }, + '.cm-placeholder': { + color: 'var(--g-color-text-secondary)', + }, + '.cm-content': { + color: 'var(--g-color-text-primary)', + caretColor: 'currentColor', + fontSize: 'var(--g-text-code-2-font-size)', + fontWeight: 'var(--g-text-code-font-weight)', + fontFamily: 'var(--g-font-family-monospace)', + lineHeight: 'var(--g-text-code-2-line-height)', + }, + '&.cm-focused .cm-cursor': { + borderLeftColor: 'currentColor', + }, + '&.cm-focused .cm-selectionBackground, &.cm-focused ::selection': { + background: 'var(--g-color-base-misc-medium)', + }, +}); diff --git a/src/markup/codemirror/index.ts b/src/markup/codemirror/index.ts new file mode 100644 index 00000000..c2772332 --- /dev/null +++ b/src/markup/codemirror/index.ts @@ -0,0 +1,4 @@ +export type {CreateCodemirrorParams} from './create'; +export {createCodemirror} from './create'; +export {ReactRendererFacet} from './react-facet'; +export {getImageDimensions, IMG_MAX_HEIGHT} from './files-upload-plugin'; diff --git a/src/markup/codemirror/pairing-chars.ts b/src/markup/codemirror/pairing-chars.ts new file mode 100644 index 00000000..70d46519 --- /dev/null +++ b/src/markup/codemirror/pairing-chars.ts @@ -0,0 +1,33 @@ +import {ChangeSet, ChangeSpec} from '@codemirror/state'; +import {EditorView} from '@codemirror/view'; + +const PAIRING_CHARS = new Map([ + ['(', ')'], + ['{', '}'], + ['[', ']'], + ['<', '>'], + + ['*', '*'], + ['~', '~'], + + ['"', '"'], + ["'", "'"], + ['`', '`'], +]); + +export const PairingCharactersExtension = EditorView.inputHandler.of((view, _1, _2, text) => { + if (!PAIRING_CHARS.has(text)) return false; + + const tr = view.state.changeByRange((range) => { + const changes: ChangeSpec[] = [{from: range.from, insert: text}]; + if (!range.empty) changes.push({from: range.to, insert: PAIRING_CHARS.get(text)}); + + const changeSet = ChangeSet.of(changes, view.state.doc.length); + + return {changes: changeSet, range: range.map(changeSet, range.empty ? 1 : 0)}; + }); + + view.dispatch(tr, {scrollIntoView: true}); + + return true; +}); diff --git a/src/markup/codemirror/react-facet.ts b/src/markup/codemirror/react-facet.ts new file mode 100644 index 00000000..a32d2b7a --- /dev/null +++ b/src/markup/codemirror/react-facet.ts @@ -0,0 +1,8 @@ +import {Facet} from '@codemirror/state'; + +import type {ReactRenderStorage} from '../../extensions'; + +export const ReactRendererFacet = Facet.define({ + combine: (value) => value[0], + static: true, +}); diff --git a/src/markup/codemirror/yfm.ts b/src/markup/codemirror/yfm.ts new file mode 100644 index 00000000..a5a3ad96 --- /dev/null +++ b/src/markup/codemirror/yfm.ts @@ -0,0 +1,139 @@ +import {Completion, CompletionSource, snippet} from '@codemirror/autocomplete'; +import {markdown, markdownLanguage} from '@codemirror/lang-markdown'; +import type {Extension} from '@codemirror/state'; +import {Tag, tags} from '@lezer/highlight'; +import type {DelimiterType, MarkdownConfig} from '@lezer/markdown'; + +import {capitalize} from '../../lodash'; + +export const customTags = { + underline: Tag.define(), + monospace: Tag.define(), + marked: Tag.define(), +}; + +function mdInlineFactory({ + name, + char, + tag, +}: { + name: string; + char: number; + tag: Tag; +}): MarkdownConfig { + const NodeName = name; + const MarkName = `${name}Mark`; + const Delim: DelimiterType = {resolve: NodeName, mark: MarkName}; + return { + defineNodes: [ + {name: NodeName, style: {[`${NodeName}/...`]: tag}}, + {name: MarkName, style: tags.processingInstruction}, + ], + parseInline: [ + { + name, + parse(cx, next, pos) { + if (next !== char || cx.char(pos + 1) !== char || cx.char(pos + 2) === char) + return -1; + + return cx.addDelimiter(Delim, pos, pos + 2, true, true); + }, + after: 'Emphasis', + }, + ], + }; +} + +const UnderlineExtension = mdInlineFactory({ + name: 'Underline', + char: '+'.charCodeAt(0), + tag: customTags.underline, +}); + +const MonospaceExtension = mdInlineFactory({ + name: 'Monospace', + char: '#'.charCodeAt(0), + tag: customTags.monospace, +}); + +const MarkedExtension = mdInlineFactory({ + name: 'Marked', + char: '='.charCodeAt(0), + tag: customTags.marked, +}); + +export type YfmNoteType = 'info' | 'tip' | 'warning' | 'alert'; +export const yfmNoteTypes: readonly YfmNoteType[] = ['info', 'tip', 'warning', 'alert']; +export const yfmNoteSnippetTemplate = (type: YfmNoteType) => + `{% note ${type} %}\n\n#{}\n\n{% endnote %}\n\n` as const; +export const yfmNoteSnippets: Record> = { + info: snippet(yfmNoteSnippetTemplate('info')), + tip: snippet(yfmNoteSnippetTemplate('tip')), + warning: snippet(yfmNoteSnippetTemplate('warning')), + alert: snippet(yfmNoteSnippetTemplate('alert')), +}; + +export const yfmCutSnippetTemplate = '{% cut "#{title}" %}\n\n#{}\n\n{% endcut %}\n\n'; +export const yfmCutSnippet = snippet(yfmCutSnippetTemplate); + +export function yfmLang(): Extension { + const mdSupport = markdown({ + // defaultCodeLanguage: markdownLanguage, + base: markdownLanguage, + addKeymap: true, + completeHTMLTags: false, + extensions: [UnderlineExtension, MonospaceExtension, MarkedExtension], + }); + + const mdAutocomplete: {autocomplete: CompletionSource} = { + autocomplete: (context) => { + // TODO: add more actions and re-enable + // let word = context.matchBefore(/\/.*/); + // if (word) { + // return { + // from: word.from, + // options: [ + // ...yfmNoteTypes.map((type, index) => ({ + // label: `/yfm note ${type}`, + // displayLabel: `YFM Note ${capitalize(type)}`, + // type: 'text', + // apply: yfmNoteSnippets[type], + // boost: -index, + // })), + // { + // label: '/yfm cut', + // displayLabel: 'YFM Cut', + // type: 'text', + // apply: yfmCutSnippet, + // }, + // ], + // }; + // } + const word = context.matchBefore(/^.*/); + if (word?.text.startsWith('{%')) { + return { + from: word.from, + options: [ + ...yfmNoteTypes.map((type, index) => ({ + label: `{% note ${type}`, + displayLabel: capitalize(type), + type: 'text', + section: 'YFM Note', + apply: yfmNoteSnippets[type], + boost: -index, + })), + { + label: '{% cut', + displayLabel: 'YFM Cut', + type: 'text', + apply: yfmCutSnippet, + }, + ], + }; + } + return null; + }, + }; + + return [mdSupport, mdSupport.language.data.of(mdAutocomplete)]; +} diff --git a/src/markup/commands.ts b/src/markup/commands.ts deleted file mode 100644 index 009c7b88..00000000 --- a/src/markup/commands.ts +++ /dev/null @@ -1,261 +0,0 @@ -import type CodeMirror from 'codemirror'; - -export const wrapToBold = wrapTo('**'); -export const wrapToItalic = wrapTo('*'); -export const wrapToStrike = wrapTo('~~'); -export const wrapToUnderline = wrapTo('++'); -export const wrapToMonospace = wrapTo('##'); -export const wrapToMark = wrapTo('=='); - -export const toH1 = toHeading(1); -export const toH2 = toHeading(2); -export const toH3 = toHeading(3); -export const toH4 = toHeading(4); -export const toH5 = toHeading(5); -export const toH6 = toHeading(6); - -export const colorify = (cm: CodeMirror.Editor, color: string) => wrapTo(`{${color}}(`, ')')(cm); - -type WrapPerLineOptions = { - beforeText: string; - afterText?: string; // or false - wrapEmptyLine?: boolean; // default false -}; - -const wrapPerLine = - ({beforeText, afterText = beforeText, wrapEmptyLine = false}: WrapPerLineOptions) => - (cm: CodeMirror.Editor) => { - const from = cm.getCursor('from'); - const to = cm.getCursor('to'); - - const lines: string[] = []; - - for (let line = from.line; line <= to.line; line++) { - lines.push(cm.getLine(line)); - } - - lines.forEach((l, i) => { - const currLine = from.line + i; - const shouldAppend = - l.length || (wrapEmptyLine && !l.length) || (l.length === 0 && lines.length === 1); - - cm.replaceRange( - shouldAppend ? beforeText + l + afterText : l, - {line: currLine, ch: 0}, - {line: currLine, ch: Number.MAX_SAFE_INTEGER}, - ); - }); - - cm.setSelection({line: to.line, ch: Number.MAX_SAFE_INTEGER}); - }; - -const needEmptyLines = (cm: CodeMirror.Editor) => { - const start = cm.getCursor('from'); - const end = cm.getCursor('to'); - - const emptyLineBefore = cm.getLine(end.line - 1)?.length || start.ch !== 0 ? '\n\n' : ''; - const emptyLineAfter = - cm.getLine(end.line + 1)?.length || end.ch !== cm.getLine(end.line).length ? '\n\n' : ''; - - return {emptyLineBefore, emptyLineAfter}; -}; - -const wrapToBlock = - (openToken: string, closeToken: string = openToken) => - (cm: CodeMirror.Editor) => { - const text = cm.getSelection(); - const start = cm.getCursor('from'); - const {emptyLineBefore, emptyLineAfter} = needEmptyLines(cm); - - cm.replaceSelection(emptyLineBefore + openToken + text + closeToken + emptyLineAfter); - cm.setSelection({ - line: start.line + (emptyLineBefore + openToken + text).split('\n').length - 1, - ch: Number.MAX_SAFE_INTEGER, - }); - }; - -export const toCheckbox = wrapPerLine({beforeText: '[ ] ', afterText: ''}); - -export const toQuote = wrapPerLine({beforeText: '> ', afterText: '', wrapEmptyLine: true}); - -export const toBulletList = wrapPerLine({beforeText: '* ', afterText: ''}); -export const toOrderedList = wrapPerLine({beforeText: '1. ', afterText: ''}); - -export const toInlineCode = wrapTo('`'); -export const toCodeBlock = wrapToBlock('```\n', '\n```'); - -export const wrapToMathInline = wrapTo('$'); -export const wrapToMathBlock = wrapToBlock('$$\n', '\n$$'); - -export const sinkListItem = wrapPerLine({beforeText: ' ', afterText: ''}); -export const liftListItem = (cm: CodeMirror.Editor) => { - const from = cm.getCursor('from'); - const to = cm.getCursor('to'); - - const lines: string[] = []; - - for (let line = from.line; line <= to.line; line++) { - lines.push(cm.getLine(line)); - } - - lines.forEach((l, i) => { - const currLine = from.line + i; - - cm.replaceRange( - l.replace(/ {2}/, ''), - {line: currLine, ch: 0}, - {line: currLine, ch: Number.MAX_SAFE_INTEGER}, - ); - }); -}; - -export const wrapToCut = wrapToBlock('{% cut "title" %}\n\n', '\n\n{% endcut %}'); - -export const wrapToNote = wrapToBlock('{% note info %}\n\n', '\n\n{% endnote %}'); - -export const wrapToYfmBlock = wrapToBlock('{% block %}\n\n', '\n\n{% endblock %}'); - -export const wrapToYfmLayout = wrapToBlock( - '{% layout gap=l %}\n\n{% block %}\n\n', - '\n\n{% endblock %}\n\n{% endlayout %}', -); - -export const insertTable = (cm: CodeMirror.Editor) => { - const tableTokens = ['#|', '||', '', '|', '', '||', '||', '', '|', '', '||', '|#', ''].join( - '\n\n', - ); - - const {emptyLineBefore, emptyLineAfter} = needEmptyLines(cm); - - cm.replaceSelection(emptyLineBefore + tableTokens + emptyLineAfter); -}; - -export const insertLink = - ({url, text, title}: {url: string; text?: string; title?: string}) => - (cm: CodeMirror.Editor) => { - if (cm.getSelection()) { - // ignore text if editor has selection - wrapTo('[', `](${url}${title ? ` "${title}"` : ''})`)(cm); - return; - } - - const markup = `[${text ?? ''}](${url}${title ? ` "${title}"` : ''})`; - cm.replaceSelection(markup); - }; - -export const insertAnchor = - ({href: href, text, title}: {href?: string; text?: string; title?: string} = {}) => - (cm: CodeMirror.Editor) => { - if (cm.getSelection()) { - // ignore text if editor has selection - wrapTo('#[', `](${href ?? ''}${title ? ` "${title}"` : ''})`)(cm); - return; - } - - const markup = `#[${text ?? ''}](${href ?? ''}${title ? ` "${title}"` : ''})`; - cm.replaceSelection(markup); - }; - -export type ImageItem = { - url?: string; - title?: string; - alt?: string; - width?: string; - height?: string; -}; -export function insertImages(cm: CodeMirror.Editor, images: ImageItem[]) { - const markup = images - .map(({title, url, alt, width, height}) => { - const titleStr = title ? ` "${title}"` : ''; - const sizeStr = width ?? height ? ` =${width ?? ''}x${height ?? ''}` : ''; - return `![${alt ?? ''}](${url ?? ''}${titleStr}${sizeStr})`; - }) - .join(' '); - cm.replaceSelection(markup); -} - -export const insertIframe = ( - cm: CodeMirror.Editor, - args: {src: string; width?: number; height?: number}, -) => { - const argsMarkup = Object.entries(args) - .map(([k, v]) => `${k}=${v}`) - .join(' '); - const markup = `/iframe/(${argsMarkup})`; - - const {emptyLineBefore, emptyLineAfter} = needEmptyLines(cm); - - cm.replaceSelection(emptyLineBefore + markup + emptyLineAfter); -}; - -export type FileItem = {src: string; name: string; type?: string}; -export const insertFiles = (cm: CodeMirror.Editor, files: FileItem[]) => { - const markup = files - .map((attrs) => { - const attrsStr = Object.entries(attrs) - .map(([key, value]) => `${key}="${value.replace('"', '')}"`) - .join(' '); - return `{% file ${attrsStr} %}`; - }) - .join(' '); - cm.replaceSelection(markup); -}; - -export const toMermaid = (cm: CodeMirror.Editor) => { - cm.replaceSelection(`\`\`\`mermaid -sequenceDiagram - Alice->>Bob: Hi Bob - Bob->>Alice: Hi Alice -\`\`\``); -}; - -function wrapTo(beforeText: string, afterText: string = beforeText) { - return function (cm: CodeMirror.Editor) { - const text = cm.getSelection(); - const start = cm.getCursor('from'); - const end = cm.getCursor('to'); - - cm.replaceSelection(beforeText + text + afterText); - cm.setSelection( - {line: start.line, ch: start.ch + beforeText.length}, - {line: end.line, ch: start.line === end.line ? end.ch + beforeText.length : end.ch}, - ); - }; -} - -function toHeading(level: 1 | 2 | 3 | 4 | 5 | 6) { - const re = /^\s*#*\s*/; - const str = Array(level).fill('#').join('') + ' '; - - return function (cm: CodeMirror.Editor) { - const startLine = cm.getCursor('from').line; - const endLine = cm.getCursor('to').line; - - if (startLine === endLine) { - replace(startLine, cm.getLine(startLine)); - return; - } - - for (let i = startLine; i <= endLine; i++) { - const lineContent = cm.getLine(i); - if (lineContent.trim().length) { - replace(i, lineContent); - } - } - - function replace(line: number, content: string) { - const newContent = content.replace(re, str); - cm.replaceRange(newContent, {line, ch: 0}, {line, ch: Number.MAX_SAFE_INTEGER}); - } - }; -} - -export function toHr(cm: CodeMirror.Editor) { - const start = cm.getCursor('from'); - cm.replaceSelection('---\n'); - cm.setSelection({line: start.line + 1, ch: 0}); -} - -export function toTabs(cm: CodeMirror.Editor) { - cm.replaceSelection(['{% list tabs %}', '', '- ', ' ', '', '{% endlist %}', ''].join('\n')); -} diff --git a/src/markup/commands/blocks.ts b/src/markup/commands/blocks.ts new file mode 100644 index 00000000..c5cf48c2 --- /dev/null +++ b/src/markup/commands/blocks.ts @@ -0,0 +1,25 @@ +import type {StateCommand} from '@codemirror/state'; + +import {replaceOrInsertAfter, wrapPerLine} from './helpers'; + +export const wrapToBlockquote = wrapPerLine({beforeText: '> ', skipEmptyLine: false}); +export const wrapToCheckbox = wrapPerLine({beforeText: '[ ] '}); + +export const insertHRule: StateCommand = ({state, dispatch}) => { + const hrMarkup = '---'; + const tr = replaceOrInsertAfter(state, hrMarkup); + dispatch(state.update(tr)); + return true; +}; + +export const insertMermaidDiagram: StateCommand = ({state, dispatch}) => { + const markup = `\`\`\`mermaid +sequenceDiagram + Alice->>Bob: Hi Bob + Bob->>Alice: Hi Alice +\`\`\``; + + const tr = replaceOrInsertAfter(state, markup); + dispatch(state.update(tr)); + return true; +}; diff --git a/src/markup/commands/code.ts b/src/markup/commands/code.ts new file mode 100644 index 00000000..38297c3c --- /dev/null +++ b/src/markup/commands/code.ts @@ -0,0 +1,38 @@ +import {ChangeSpec, EditorSelection, StateCommand} from '@codemirror/state'; + +import {wrapToBlock} from './helpers'; + +export const wrapToCodeBlock: StateCommand = wrapToBlock( + ({lineBreak}) => '```' + lineBreak, + ({lineBreak}) => lineBreak + '```', +); + +export const wrapToInlineCode: StateCommand = ({state, dispatch}) => { + const tr = state.changeByRange((range) => { + const content = state.sliceDoc(range.from, range.to); + + const hasBacktick = content.includes('`'); + const markup = hasBacktick ? '``' : '`'; + const before = `${markup}${content.startsWith('`') ? ' ' : ''}`; + const after = `${content.endsWith('`') ? ' ' : ''}${markup}`; + + const changeSpec: ChangeSpec[] = [ + {from: range.from, insert: before}, + {from: range.to, insert: after}, + ]; + const changes = state.changes(changeSpec); + return { + changes, + range: range.empty + ? EditorSelection.range( + range.anchor + 1, + range.head + 1, + range.goalColumn, + range.bidiLevel ?? undefined, + ) + : range.map(changes), + }; + }); + dispatch(state.update(tr)); + return true; +}; diff --git a/src/markup/commands/heading.ts b/src/markup/commands/heading.ts new file mode 100644 index 00000000..b4944a12 --- /dev/null +++ b/src/markup/commands/heading.ts @@ -0,0 +1,53 @@ +import {ChangeSpec, EditorSelection, StateCommand} from '@codemirror/state'; + +export const toH1 = toHeading(1); +export const toH2 = toHeading(2); +export const toH3 = toHeading(3); +export const toH4 = toHeading(4); +export const toH5 = toHeading(5); +export const toH6 = toHeading(6); + +function toHeading(level: 1 | 2 | 3 | 4 | 5 | 6): StateCommand { + const re = /^\s*#*\s*/; + const str = Array(level).fill('#').join('') + ' '; + return ({state, dispatch}) => { + const trSpec = state.changeByRange((range) => { + const lineFrom = state.doc.lineAt(range.from); + const lineTo = state.doc.lineAt(range.to); + + // cursor in empty line + if (lineFrom.number === lineTo.number && lineFrom.length === 0) { + return { + changes: [{from: lineFrom.from, insert: str}], + // move cursor to end of inserted string with sharps (#) + range: EditorSelection.range( + range.anchor + str.length, + range.head + str.length, + range.goalColumn, + range.bidiLevel ?? undefined, + ), + }; + } + + // multiline selection + const changes: ChangeSpec[] = []; + for (let i = lineFrom.number; i <= lineTo.number; i++) { + const line = state.doc.line(i); + // ignore empty line in multiline selection + if (line.length === 0 && !range.empty) continue; + + // replace sharps (#) at start of line + const replacedLength = re.exec(line.text)?.[0].length ?? 0; + changes.push({from: line.from, to: line.from + replacedLength, insert: str}); + } + + const changeSet = state.changes(changes); + return { + changes: changeSet, + range: range.map(changeSet), + }; + }); + dispatch(state.update(trSpec)); + return true; + }; +} diff --git a/src/markup/commands/helpers.ts b/src/markup/commands/helpers.ts new file mode 100644 index 00000000..94629278 --- /dev/null +++ b/src/markup/commands/helpers.ts @@ -0,0 +1,236 @@ +import { + ChangeSpec, + EditorSelection, + EditorState, + Line, + SelectionRange, + StateCommand, + Text, + TransactionSpec, +} from '@codemirror/state'; + +export function getBlockExtraLineBreaks( + state: EditorState, + {from: fromLine, to: toLine}: {from: Line; to: Line}, +) { + let lineBreaksBefore = 0; + if (fromLine.number === 1 || state.doc.line(fromLine.number - 1).length === 0) + lineBreaksBefore = 0; + else lineBreaksBefore = 1; + + let lineBreaksAfter = 0; + if (toLine.number === state.doc.lines || state.doc.line(fromLine.number + 1).length === 0) + lineBreaksAfter = 0; + else lineBreaksAfter = 1; + + return {before: lineBreaksBefore, after: lineBreaksAfter}; +} + +export function replaceOrInsertAfter(state: EditorState, markup: string): TransactionSpec { + const selrange = state.selection.main; + if (isFullLinesSelection(state.doc, selrange)) { + const extraBreaks = getBlockExtraLineBreaks(state, { + from: state.doc.lineAt(selrange.from), + to: state.doc.lineAt(selrange.to), + }); + return state.replaceSelection( + state.lineBreak.repeat(extraBreaks.before) + + markup + + state.lineBreak.repeat(extraBreaks.after), + ); + } else { + const insert = state.lineBreak.repeat(2) + markup + state.lineBreak.repeat(2); + const from = state.doc.lineAt(selrange.to).to; + const selAnchor = from + insert.length - 2; + return {changes: {from, insert}, selection: {anchor: selAnchor}}; + } +} + +function isFullLinesSelection(doc: Text, range: SelectionRange): boolean { + const fromLine = doc.lineAt(range.from); + const toLine = doc.lineAt(range.to); + return range.from <= fromLine.from && range.to >= toLine.to; +} + +export const wrapToBlock = ( + before: string | ((arg: Pick) => string), + after: string | ((arg: Pick) => string), + perLine?: { + before: string | ((arg: Pick) => string); + after: string | ((arg: Pick) => string); + /** @default false */ + skipEmptyLine?: boolean; + }, +): StateCommand => { + return ({state, dispatch}) => { + const beforeText = typeof before === 'function' ? before(state) : before; + const afterText = typeof after === 'function' ? after(state) : after; + + const selrange = state.selection.main; + const fromLine = state.doc.lineAt(selrange.from); + const toLine = state.doc.lineAt(selrange.to); + const extraBreaks = getBlockExtraLineBreaks(state, {from: fromLine, to: toLine}); + + const beforeInsertion = state.lineBreak.repeat(extraBreaks.before) + beforeText; + const afterInsertion = afterText + state.lineBreak.repeat(extraBreaks.after); + const changeSpec: ChangeSpec[] = [{from: fromLine.from, insert: beforeInsertion}]; + + const isEmptyLine = fromLine.number === toLine.number && fromLine.length === 0; + let cursorShift = selrange.head + beforeInsertion.length; + + if (perLine) { + const lineBeforeText = + typeof perLine.before === 'function' ? perLine.before(state) : perLine.before; + const lineAfterText = + typeof perLine.after === 'function' ? perLine.after(state) : perLine.after; + + iterateOverRangeLines(state.doc, selrange, (line) => { + if (perLine.skipEmptyLine && line.length === 0) return; + if (lineBeforeText) { + changeSpec.push({from: line.from, insert: lineBeforeText}); + if (isEmptyLine) cursorShift += lineBeforeText.length; + } + if (lineAfterText) changeSpec.push({from: line.to, insert: lineAfterText}); + }); + } + + changeSpec.push({from: toLine.to, insert: afterInsertion}); + + const changes = state.changes(changeSpec); + dispatch( + state.update({ + changes, + selection: isEmptyLine + ? EditorSelection.single(cursorShift) + : state.selection.map(changes), + }), + ); + + return true; + }; +}; + +export function inlineWrapTo(before: string, after: string = before): StateCommand { + return ({state, dispatch}) => { + const trSpec = state.changeByRange((range) => { + const changes = state.changes([ + {from: range.from, insert: before}, + {from: range.to, insert: after}, + ]); + return { + changes, + range: range.empty + ? EditorSelection.range( + range.anchor + before.length, + range.head + before.length, + range.goalColumn, + range.bidiLevel ?? undefined, + ) + : range.map(changes), + }; + }); + dispatch(state.update(trSpec)); + return true; + }; +} + +export function toggleInlineMarkupFactory( + markup: string | {before: string; after?: string}, +): StateCommand { + const [before, after] = + typeof markup === 'string' + ? [markup, markup] + : [markup.before, markup.after ?? markup.before]; + const beforeLength = before.length; + const afterLength = after.length; + + return ({state, dispatch}) => { + const tr: TransactionSpec = state.changeByRange((range) => { + const hasMarkupBefore = + state.sliceDoc(range.from - beforeLength, range.from) === before; + const hasMarkupAfter = state.sliceDoc(range.to, range.to + afterLength) === after; + + const changeSpec: ChangeSpec[] = []; + if (hasMarkupBefore && hasMarkupAfter) { + changeSpec.push( + {from: range.from - beforeLength, to: range.from, insert: ''}, + {from: range.to, to: range.to + afterLength, insert: ''}, + ); + } else { + if (!hasMarkupBefore) { + changeSpec.push({ + from: range.from, + insert: before, + }); + } + if (!hasMarkupAfter) { + changeSpec.push({ + from: range.to, + insert: after, + }); + } + } + + const changes = state.changes(changeSpec); + + return { + changes, + range: + range.empty && !hasMarkupBefore + ? EditorSelection.range( + range.anchor + beforeLength, + range.head + beforeLength, + range.goalColumn, + range.bidiLevel ?? undefined, + ) + : range.map(changes), + }; + }); + + tr.scrollIntoView = true; + + dispatch(state.update(tr)); + + return true; + }; +} + +type WrapPerLineOptions = { + beforeText: string; + afterText?: string; // or false + skipEmptyLine?: boolean; // default false +}; + +export const wrapPerLine = + ({beforeText: before, skipEmptyLine = true}: WrapPerLineOptions): StateCommand => + ({state, dispatch}) => { + const tr = state.changeByRange((range) => { + const changes: ChangeSpec[] = []; + + const isSingleEmptyLine = + range.anchor === range.head && state.doc.lineAt(range.head).length === 0; + if (isSingleEmptyLine) { + changes.push({from: range.head, insert: before}); + } else { + iterateOverRangeLines(state.doc, range, (line) => { + if (skipEmptyLine && line.length === 0) return; + changes.push({from: line.from, insert: before}); + }); + } + + const changeSet = state.changes(changes); + return {changes: changeSet, range: range.map(changeSet, 1)}; + }); + + dispatch(state.update(tr)); + return true; + }; + +export function iterateOverRangeLines(doc: Text, range: SelectionRange, fn: (line: Line) => void) { + const from = doc.lineAt(range.from).number; + const to = doc.lineAt(range.to).number; + + for (let i = from; i <= to; i++) { + fn(doc.line(i)); + } +} diff --git a/src/markup/commands/index.ts b/src/markup/commands/index.ts new file mode 100644 index 00000000..08642111 --- /dev/null +++ b/src/markup/commands/index.ts @@ -0,0 +1,10 @@ +export {redo, redoDepth, undo, undoDepth} from '@codemirror/commands'; + +export * from './blocks'; +export * from './code'; +export * from './heading'; +export * from './inline'; +export * from './lists'; +export * from './marks'; +export * from './math'; +export * from './yfm'; diff --git a/src/markup/commands/inline.ts b/src/markup/commands/inline.ts new file mode 100644 index 00000000..956ba654 --- /dev/null +++ b/src/markup/commands/inline.ts @@ -0,0 +1,69 @@ +import {snippet} from '@codemirror/autocomplete'; +import type {StateCommand} from '@codemirror/state'; + +const defaultLinkSnippet = snippet(`[#{2:link}](#{1:url} "#{3:title}")`); +export const insertLink: StateCommand = ({state, dispatch}) => { + const {from, to, empty} = state.selection.main; + const linkSnippet = empty + ? defaultLinkSnippet + : snippet(`[#{2:${state.sliceDoc(from, to)}}](#{1:url} "#{3:title}")`); + linkSnippet({state, dispatch}, null, from, to); + return true; +}; + +const defaultAnchorSnippet = snippet(`#[#{2:text}](#{1:anchor} "#{3:title}")`); +export const insertAnchor: StateCommand = ({state, dispatch}) => { + const {from, to, empty} = state.selection.main; + const anchorSnippet = empty + ? defaultAnchorSnippet + : snippet(`#[#{2:${state.sliceDoc(from, to)}}](#{1:anchor} "#{3:title}")`); + anchorSnippet({state, dispatch}, null, from, to); + return true; +}; + +export type ImageItem = { + url?: string; + title?: string; + alt?: string; + width?: string; + height?: string; +}; +export function insertImages(images: ImageItem[]): StateCommand { + return ({state, dispatch}) => { + const markup = images + .map(({title, url, alt, width, height}) => { + const titleStr = title ? ` "${title}"` : ''; + const sizeStr = width ?? height ? ` =${width ?? ''}x${height ?? ''}` : ''; + return `![${alt ?? ''}](${url ?? ''}${titleStr}${sizeStr})`; + }) + .join(' '); + + const tr = state.changeByRange((range) => { + const changes = state.changes({from: range.from, to: range.to, insert: markup}); + return {changes, range: range.map(changes)}; + }); + dispatch(state.update(tr)); + return true; + }; +} + +export type FileItem = {src: string; name: string; type?: string}; +export const insertFiles = (files: FileItem[]): StateCommand => { + return ({state, dispatch}) => { + const markup = files + .map((attrs) => { + const attrsStr = Object.entries(attrs) + .map(([key, value]) => `${key}="${value.replace('"', '')}"`) + .join(' '); + return `{% file ${attrsStr} %}`; + }) + .join(' '); + + const tr = state.changeByRange((range) => { + const changes = state.changes({from: range.from, to: range.to, insert: markup}); + return {changes, range: range.map(changes)}; + }); + dispatch(state.update(tr)); + return true; + }; +}; diff --git a/src/markup/commands/lists.ts b/src/markup/commands/lists.ts new file mode 100644 index 00000000..bbf586b2 --- /dev/null +++ b/src/markup/commands/lists.ts @@ -0,0 +1,26 @@ +import type {ChangeSpec, StateCommand} from '@codemirror/state'; + +import {iterateOverRangeLines, wrapPerLine} from './helpers'; + +export const toBulletList = wrapPerLine({beforeText: '- '}); +export const toOrderedList = wrapPerLine({beforeText: '1. '}); + +export const sinkListItem = wrapPerLine({beforeText: ' ', skipEmptyLine: false}); +export const liftListItem: StateCommand = ({state, dispatch}) => { + const tr = state.changeByRange((range) => { + const changeSpec: ChangeSpec[] = []; + + iterateOverRangeLines(state.doc, range, (line) => { + if (line.text.startsWith(' ')) + changeSpec.push({from: line.from, to: line.from + 2, insert: ''}); + else if (line.text.startsWith(' ')) + changeSpec.push({from: line.from, to: line.from + 1, insert: ''}); + }); + + const changes = state.changes(changeSpec); + return {changes, range: range.map(changes)}; + }); + + dispatch(state.update(tr)); + return true; +}; diff --git a/src/markup/commands/marks.ts b/src/markup/commands/marks.ts new file mode 100644 index 00000000..35baf807 --- /dev/null +++ b/src/markup/commands/marks.ts @@ -0,0 +1,10 @@ +import {inlineWrapTo, toggleInlineMarkupFactory} from './helpers'; + +export const colorify = (color: string) => inlineWrapTo(`{${color}}(`, ')'); + +export const toggleBold = toggleInlineMarkupFactory('**'); +export const toggleItalic = toggleInlineMarkupFactory('_'); +export const toggleStrikethrough = toggleInlineMarkupFactory('~~'); +export const toggleUnderline = toggleInlineMarkupFactory('++'); +export const toggleMonospace = toggleInlineMarkupFactory('##'); +export const toggleMarked = toggleInlineMarkupFactory('=='); diff --git a/src/markup/commands/math.ts b/src/markup/commands/math.ts new file mode 100644 index 00000000..a06eba9d --- /dev/null +++ b/src/markup/commands/math.ts @@ -0,0 +1,7 @@ +import {inlineWrapTo, wrapToBlock} from './helpers'; + +export const wrapToMathInline = inlineWrapTo('$'); +export const wrapToMathBlock = wrapToBlock( + ({lineBreak}) => '$$' + lineBreak, + ({lineBreak}) => lineBreak + '$$', +); diff --git a/src/markup/commands/yfm.ts b/src/markup/commands/yfm.ts new file mode 100644 index 00000000..b8a7e890 --- /dev/null +++ b/src/markup/commands/yfm.ts @@ -0,0 +1,24 @@ +import type {StateCommand} from '@codemirror/state'; + +import {wrapToBlock} from './helpers'; + +export const wrapToYfmCut: StateCommand = wrapToBlock( + ({lineBreak}) => '{% cut "title" %}' + lineBreak.repeat(2), + ({lineBreak}) => lineBreak.repeat(2) + '{% endcut %}', +); + +export const wrapToYfmNote = wrapToBlock( + ({lineBreak}) => '{% note info %}' + lineBreak.repeat(2), + ({lineBreak}) => lineBreak.repeat(2) + '{% endnote %}', +); + +export const insertYfmTabs = wrapToBlock( + ({lineBreak}) => '{% list tabs %}' + lineBreak.repeat(2) + '- Tab name' + lineBreak.repeat(2), + ({lineBreak}) => lineBreak.repeat(2) + '{% endlist %}', + {before: ' ', after: ''}, +); + +export const insertYfmTable = wrapToBlock( + ({lineBreak}) => ['#|', '||'].join(lineBreak) + lineBreak, + ({lineBreak}) => lineBreak + ['|', '', '||', '||', '', '|', '', '||', '|#', ''].join(lineBreak), +); diff --git a/src/markup/editor.test.ts b/src/markup/editor.test.ts index 7321e664..3e43e9dd 100644 --- a/src/markup/editor.test.ts +++ b/src/markup/editor.test.ts @@ -1,99 +1,96 @@ -import CodeMirror from 'codemirror'; +import {EditorView} from '@codemirror/view'; -import {MarkupContentHandler} from './editor'; +import {Editor} from './editor'; describe('MarkupContentHandler', () => { it('should clear the document', () => { - const cm = CodeMirror(() => {}, { - value: 'codemirror\n\ncontent\n', + const cm = new EditorView({ + doc: 'codemirror\n\ncontent\n', }); - const contentHandler = new MarkupContentHandler(cm); + const contentHandler = new Editor(cm); contentHandler.clear(); - expect(cm.getValue()).toBe(''); + expect(cm.state.sliceDoc()).toBe(''); }); it('should append markup to empty document', () => { - const cm = CodeMirror(() => {}); - const contentHandler = new MarkupContentHandler(cm); + const cm = new EditorView(); + const contentHandler = new Editor(cm); contentHandler.append('appended\n\ncontent\n'); - const cursorPos = cm.getCursor(); - expect(cm.getValue()).toBe('appended\n\ncontent\n\n'); - expect(cursorPos.line).toBe(4); - expect(cursorPos.ch).toBe(0); + const selrange = cm.state.selection.main; + expect(cm.state.sliceDoc()).toBe('appended\n\ncontent\n\n'); + expect(selrange.from).toBe(19); + expect(selrange.empty).toBe(true); }); it('should append markup to not empty document', () => { - const cm = CodeMirror(() => {}, { - value: 'codemirror\ncontent', + const cm = new EditorView({ + doc: 'codemirror\ncontent', + selection: {anchor: 16}, }); - cm.setCursor({line: 1, ch: 5}); - const contentHandler = new MarkupContentHandler(cm); + const contentHandler = new Editor(cm); contentHandler.append('appended\ncontent'); - const cursorPos = cm.getCursor(); - expect(cm.getValue()).toBe('codemirror\ncontent\n\nappended\ncontent'); - expect(cursorPos.line).toBe(1); - expect(cursorPos.ch).toBe(5); + const selrange = cm.state.selection.main; + expect(cm.state.sliceDoc()).toBe('codemirror\ncontent\n\nappended\ncontent'); + expect(selrange.from).toBe(16); }); it('should prepend markup to empty document', () => { - const cm = CodeMirror(() => {}); - const contentHandler = new MarkupContentHandler(cm); + const cm = new EditorView(); + const contentHandler = new Editor(cm); contentHandler.prepend('prepended\n\ncontent\n'); - const cursorPos = cm.getCursor(); - expect(cm.getValue()).toBe('prepended\n\ncontent\n\n'); - expect(cursorPos.line).toBe(4); - expect(cursorPos.ch).toBe(0); + const selrange = cm.state.selection.main; + expect(cm.state.sliceDoc()).toBe('prepended\n\ncontent\n\n'); + expect(selrange.from).toBe(20); }); it('should prepend markup to not empty document', () => { - const cm = CodeMirror(() => {}, { - value: 'codemirror\ncontent', + const cm = new EditorView({ + doc: 'codemirror\ncontent', + selection: {anchor: 16}, }); - cm.setCursor({line: 1, ch: 5}); - const contentHandler = new MarkupContentHandler(cm); + const contentHandler = new Editor(cm); contentHandler.prepend('prepended\ncontent'); - const cursorPos = cm.getCursor(); - expect(cm.getValue()).toBe('prepended\ncontent\n\ncodemirror\ncontent'); - expect(cursorPos.line).toBe(4); - expect(cursorPos.ch).toBe(5); + const selrange = cm.state.selection.main; + expect(cm.state.sliceDoc()).toBe('prepended\ncontent\n\ncodemirror\ncontent'); + expect(selrange.from).toBe(35); }); it('should replace markup', () => { - const cm = CodeMirror(() => {}, { - value: 'codemirror\ncontent', + const cm = new EditorView({ + doc: 'codemirror\ncontent', }); - const contentHandler = new MarkupContentHandler(cm); + const contentHandler = new Editor(cm); contentHandler.replace('replaced\ncontent'); - expect(cm.getValue()).toBe('replaced\ncontent'); + expect(cm.state.sliceDoc()).toBe('replaced\ncontent'); }); it('should move cursor to start of document', () => { - const cm = CodeMirror(() => {}, { - value: 'codemirror\ncontent', + const cm = new EditorView({ + doc: 'codemirror\ncontent', // length=18 + selection: {anchor: 15}, }); - cm.setCursor({line: 2, ch: 5}); - const contentHandler = new MarkupContentHandler(cm); + const contentHandler = new Editor(cm); contentHandler.moveCursor('start'); - expect(cm.getCursor()).toStrictEqual({line: 0, ch: 0}); + const selrange = cm.state.selection.main; + expect(selrange.from).toBe(0); }); it('should move cursor to end of document', () => { - const cm = CodeMirror(() => {}, { - value: 'codemirror\ncontent', + const cm = new EditorView({ + doc: 'codemirror\ncontent', // length=18 + selection: {anchor: 0}, }); - cm.setCursor({line: 0, ch: 0}); - const contentHandler = new MarkupContentHandler(cm); + const contentHandler = new Editor(cm); contentHandler.moveCursor('end'); - const cursor = cm.getCursor(); - expect(cursor.line).toBe(1); - expect(cursor.ch).toBe(7); + const selrange = cm.state.selection.main; + expect(selrange.from).toBe(18); }); it('should throw an error when calling moveCursor with an unknown position', () => { - const cm = CodeMirror(() => {}, { - value: 'codemirror\ncontent', + const cm = new EditorView({ + doc: 'codemirror\ncontent', }); - const contentHandler = new MarkupContentHandler(cm); + const contentHandler = new Editor(cm); const fn = jest.fn(() => { contentHandler.moveCursor('test' as 'start'); }); diff --git a/src/markup/editor.ts b/src/markup/editor.ts index 09caf56b..b3c7ac22 100644 --- a/src/markup/editor.ts +++ b/src/markup/editor.ts @@ -1,74 +1,13 @@ -import type CodeMirror from 'codemirror'; -import type {Position} from 'codemirror'; +import type {EditorView} from '@codemirror/view'; -import {CommonEditor, ContentHandler, MarkupString} from '../common'; +import {CommonEditor, MarkupString} from '../common'; export interface CodeEditor { - readonly cm: CodeMirror.Editor; -} - -export class MarkupContentHandler implements ContentHandler { - #cm: CodeMirror.Editor; - - constructor(cm: CodeMirror.Editor) { - this.#cm = cm; - } - - clear(): void { - this.replace(''); - } - - replace(newMarkup: MarkupString): void { - this.#cm.setValue(newMarkup); - } - - prepend(markup: MarkupString): void { - const cursor = this.#cm.getCursor(); - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - if (markup.endsWith('\n\n')) markup; - else if (markup.endsWith('\n')) markup += '\n'; - else markup += '\n\n'; - const markupLinesCount = markup.split('\n').length - 1; - this.replace(markup + this.#cm.getValue()); - this.#cm.setCursor({line: cursor.line + markupLinesCount, ch: cursor.ch}); - } - - append(markup: MarkupString): void { - const value = this.#cm.getValue(); - if (value === '') { - if (markup.endsWith('\n\n')) this.replace(markup); - else if (markup.endsWith('\n')) this.replace(markup + '\n'); - else this.replace(markup + '\n\n'); - this.moveCursor('end'); - return; - } - - const cursor = this.#cm.getCursor(); - if (value.endsWith('\n\n')) this.replace(value + markup); - else if (value.endsWith('\n')) this.replace(value + '\n' + markup); - else this.replace(value + '\n\n' + markup); - this.#cm.setCursor(cursor); - } - - moveCursor(position: 'start' | 'end'): void { - let pos: Position; - switch (position) { - case 'start': - pos = {line: this.#cm.firstLine(), ch: 0}; - break; - case 'end': - pos = {line: this.#cm.lastLine(), ch: Number.MAX_SAFE_INTEGER}; - break; - default: - throw new Error('The "position" argument must be "start" or "end"'); - } - this.#cm.setSelection(pos); - } + readonly cm: EditorView; } export class Editor implements CommonEditor, CodeEditor { - #cm: CodeMirror.Editor; - #contentHandler: ContentHandler; + #cm: EditorView; get cm() { return this.#cm; @@ -78,9 +17,8 @@ export class Editor implements CommonEditor, CodeEditor { return this.#cm; } - constructor(cm: CodeMirror.Editor) { + constructor(cm: EditorView) { this.#cm = cm; - this.#contentHandler = new MarkupContentHandler(cm); } focus(): void { @@ -88,34 +26,76 @@ export class Editor implements CommonEditor, CodeEditor { } hasFocus(): boolean { - return this.#cm.hasFocus(); + return this.#cm.hasFocus; } getValue(): MarkupString { - return this.#cm.getValue(); + return this.#cm.state.doc.toString(); } isEmpty(): boolean { - return this.#cm.lineCount() === 1 && this.#cm.getLine(0).trim().length === 0; + return ( + this.#cm.state.doc.lines === 1 && this.#cm.state.doc.line(1).text.trim().length === 0 + ); } clear(): void { - return this.#contentHandler.clear(); + this.replace(''); } replace(newMarkup: MarkupString): void { - return this.#contentHandler.replace(newMarkup); + this.#cm.dispatch({changes: {from: 0, to: this.#cm.state.doc.length, insert: newMarkup}}); } prepend(markup: MarkupString): void { - return this.#contentHandler.prepend(markup); + const changes = this.#cm.state.changes({ + from: 0, + insert: this._fixMarkupBeforeInsert(markup), + }); + this.#cm.dispatch({changes, selection: this.#cm.state.selection.map(changes, 1)}); } append(markup: MarkupString): void { - return this.#contentHandler.append(markup); + if (this.isEmpty()) { + this.#cm.dispatch({changes: {from: 0, insert: this._fixMarkupBeforeInsert(markup)}}); + this.moveCursor('end'); + return; + } + + const {lineBreak} = this.#cm.state; + let insert: string; + const { + doc, + doc: {lines}, + } = this.#cm.state; + if (lines >= 2 && doc.lineAt(lines).length === 0) { + if (doc.lineAt(lines - 1).length === 0) insert = markup; + else insert = lineBreak + markup; + } else { + insert = lineBreak + lineBreak + markup; + } + + this.#cm.dispatch({changes: {from: doc.length, insert}}); } moveCursor(position: 'start' | 'end'): void { - this.#contentHandler.moveCursor(position); + let pos: number; + switch (position) { + case 'start': + pos = 0; + break; + case 'end': + pos = this.#cm.state.doc.length; + break; + default: + throw new Error('The "position" argument must be "start" or "end"'); + } + this.#cm.dispatch({selection: {anchor: pos}}); + } + + private _fixMarkupBeforeInsert(markup: MarkupString): MarkupString { + if (markup.endsWith('\n\n')) return markup; + if (markup.endsWith('\n')) return markup + '\n'; + return markup + '\n\n'; } } diff --git a/src/markup/index.ts b/src/markup/index.ts index c6ac7db2..27d2281f 100644 --- a/src/markup/index.ts +++ b/src/markup/index.ts @@ -1,3 +1,3 @@ -export * from './active'; +export * from './codemirror'; export * from './commands'; export * from './editor'; diff --git a/src/shortcuts/formatter.ts b/src/shortcuts/formatter.ts index 7e978144..c7eae850 100644 --- a/src/shortcuts/formatter.ts +++ b/src/shortcuts/formatter.ts @@ -1,4 +1,3 @@ -import {capitalize} from '../lodash'; import {isMac} from '../utils/platform'; import {cmChars} from './chars'; @@ -32,7 +31,6 @@ class ShortCutFormatter { return defs .map((str) => cmChars[str] ?? str) .sort((a) => (a === MK.Shift ? -1 : 0)) - .map(capitalize) .join('-'); } diff --git a/src/utils/sync-scroll.ts b/src/utils/sync-scroll.ts index dbb6ff0e..d5884196 100644 --- a/src/utils/sync-scroll.ts +++ b/src/utils/sync-scroll.ts @@ -1,4 +1,4 @@ -import React from 'react'; +import type {RefObject} from 'react'; export type CodeLineElement = {el: HTMLElement; line: number}; @@ -76,7 +76,7 @@ export function scrollToRevealSourceLine( export function getEditorLineNumberForOffset( offset: number, lines: CodeLineElement[], - outerRef: React.RefObject, + outerRef: RefObject, ): number | null { if (!outerRef.current) return null; const {previous, next} = getLineElementsAtPageOffset(offset, lines, outerRef); @@ -101,7 +101,7 @@ export function getEditorLineNumberForOffset( export function getLineElementsAtPageOffset( offset: number, lines: CodeLineElement[], - outerRef: React.RefObject, + outerRef: RefObject, ): { previous: CodeLineElement | null; next?: CodeLineElement;