From d275d0b1e94e3c5e37e9185ff48fc045cee6c29e Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 16 May 2024 15:51:01 +0200 Subject: [PATCH 01/17] enh(editorFactory): only use named exports Signed-off-by: Max --- src/EditorFactory.js | 10 +++------- src/tests/builders.js | 2 +- src/tests/helpers.js | 2 +- src/tests/markdown.spec.js | 2 +- src/tests/tiptap.spec.js | 2 +- 5 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/EditorFactory.js b/src/EditorFactory.js index cdb667a5681..2c6472c1757 100644 --- a/src/EditorFactory.js +++ b/src/EditorFactory.js @@ -33,7 +33,7 @@ import { FocusTrap, Mention, PlainText, RichText } from './extensions/index.js' // eslint-disable-next-line import/no-named-as-default import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight' -const loadSyntaxHighlight = async (language) => { +export const loadSyntaxHighlight = async (language) => { const list = hljs.listLanguages() logger.debug('Supported languages', { list }) if (!lowlight.listLanguages().includes(language)) { @@ -49,7 +49,7 @@ const loadSyntaxHighlight = async (language) => { } } -const createEditor = ({ language, onCreate = () => {}, onUpdate = () => {}, extensions, enableRichEditing, session, relativePath, isEmbedded = false }) => { +export const createEditor = ({ language, onCreate = () => {}, onUpdate = () => {}, extensions, enableRichEditing, session, relativePath, isEmbedded = false }) => { let defaultExtensions if (enableRichEditing) { defaultExtensions = [ @@ -70,7 +70,6 @@ const createEditor = ({ language, onCreate = () => {}, onUpdate = () => {}, exte } else { defaultExtensions = [PlainText, CodeBlockLowlight.configure({ lowlight, defaultLanguage: language })] } - return new Editor({ onCreate, onUpdate, @@ -82,9 +81,6 @@ const createEditor = ({ language, onCreate = () => {}, onUpdate = () => {}, exte }) } -const serializePlainText = (doc) => { +export const serializePlainText = (doc) => { return doc.textContent } - -export default createEditor -export { createEditor, serializePlainText, loadSyntaxHighlight } diff --git a/src/tests/builders.js b/src/tests/builders.js index 5f571f0de06..a3f7de05526 100644 --- a/src/tests/builders.js +++ b/src/tests/builders.js @@ -1,7 +1,7 @@ import { expect } from '@jest/globals'; import { Mark, Node } from '@tiptap/pm/model' import { builders } from 'prosemirror-test-builder' -import createEditor from '../EditorFactory' +import { createEditor } from '../EditorFactory' export function getBuilders() { diff --git a/src/tests/helpers.js b/src/tests/helpers.js index 38c17153d6a..401dec40565 100644 --- a/src/tests/helpers.js +++ b/src/tests/helpers.js @@ -5,7 +5,7 @@ import Document from '@tiptap/extension-document' import Paragraph from '../nodes/Paragraph' import Text from '@tiptap/extension-text' -import createEditor from '../EditorFactory' +import { createEditor } from '../EditorFactory' import markdownit from '../markdownit' export function createCustomEditor({ content, extensions }) { diff --git a/src/tests/markdown.spec.js b/src/tests/markdown.spec.js index 7581ea66ee6..d473116679b 100644 --- a/src/tests/markdown.spec.js +++ b/src/tests/markdown.spec.js @@ -6,7 +6,7 @@ import { markdownFromPaste } from './helpers.js' import { createMarkdownSerializer } from "../extensions/Markdown"; -import createEditor from "../EditorFactory"; +import { createEditor } from "../EditorFactory"; /* * This file is for various markdown tests, mainly testing if input and output stays the same. diff --git a/src/tests/tiptap.spec.js b/src/tests/tiptap.spec.js index 1a5fbb27a97..a0a615ef982 100644 --- a/src/tests/tiptap.spec.js +++ b/src/tests/tiptap.spec.js @@ -1,4 +1,4 @@ -import createEditor from '../EditorFactory' +import { createEditor } from '../EditorFactory' import markdownit from '../markdownit' const renderedHTML = ( markdown ) => { From 0d8f3b24c1613aff604c28c54a339a95474fc517 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 16 May 2024 16:05:45 +0200 Subject: [PATCH 02/17] cleanup(deps): remove proxy-polyfill Signed-off-by: Max --- package-lock.json | 11 ----------- package.json | 1 - src/EditorFactory.js | 2 -- 3 files changed, 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index d3796fdc28e..b00e21021bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,7 +74,6 @@ "mermaid": "^10.9.1", "mitt": "^3.0.1", "path-normalize": "^6.0.13", - "proxy-polyfill": "^0.3.2", "slug": "^9.0.0", "tippy.js": "^6.3.7", "uuid": "^9.0.1", @@ -22399,11 +22398,6 @@ "integrity": "sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==", "dev": true }, - "node_modules/proxy-polyfill": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/proxy-polyfill/-/proxy-polyfill-0.3.2.tgz", - "integrity": "sha512-ENKSXOMCewnQTOyqrQXxEjIhzT6dy572mtehiItbDoIUF5Sv5UkmRUc8kowg2MFvr232Uo8rwRpNg3V5kgTKbA==" - }, "node_modules/prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -43809,11 +43803,6 @@ "integrity": "sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==", "dev": true }, - "proxy-polyfill": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/proxy-polyfill/-/proxy-polyfill-0.3.2.tgz", - "integrity": "sha512-ENKSXOMCewnQTOyqrQXxEjIhzT6dy572mtehiItbDoIUF5Sv5UkmRUc8kowg2MFvr232Uo8rwRpNg3V5kgTKbA==" - }, "prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", diff --git a/package.json b/package.json index 0165c5e427a..1a019341421 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,6 @@ "mermaid": "^10.9.1", "mitt": "^3.0.1", "path-normalize": "^6.0.13", - "proxy-polyfill": "^0.3.2", "slug": "^9.0.0", "tippy.js": "^6.3.7", "uuid": "^9.0.1", diff --git a/src/EditorFactory.js b/src/EditorFactory.js index 2c6472c1757..1924f5d6e0e 100644 --- a/src/EditorFactory.js +++ b/src/EditorFactory.js @@ -22,8 +22,6 @@ import MentionSuggestion from './components/Suggestion/Mention/suggestions.js' -import 'proxy-polyfill' - import { Editor } from '@tiptap/core' import { lowlight } from 'lowlight/lib/core.js' import hljs from 'highlight.js/lib/core' From f2609f5f69153e121f7e0a882aa7fac722e1de3e Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 16 May 2024 16:24:52 +0200 Subject: [PATCH 03/17] cleanup(EditorFactory): inline serializPlainText Also drop the content arg to the serialize function as it is never provided Signed-off-by: Max --- src/EditorFactory.js | 4 ---- src/components/Editor.vue | 6 +++--- src/tests/plaintext.spec.js | 4 ++-- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/EditorFactory.js b/src/EditorFactory.js index 1924f5d6e0e..42c3c3088db 100644 --- a/src/EditorFactory.js +++ b/src/EditorFactory.js @@ -78,7 +78,3 @@ export const createEditor = ({ language, onCreate = () => {}, onUpdate = () => { extensions: defaultExtensions.concat(extensions || []), }) } - -export const serializePlainText = (doc) => { - return doc.textContent -} diff --git a/src/components/Editor.vue b/src/components/Editor.vue index d6a3482cc18..c87e14a53d1 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -114,7 +114,7 @@ import { SyncService, ERROR_TYPE, IDLE_TIMEOUT } from './../services/SyncService import createSyncServiceProvider from './../services/SyncServiceProvider.js' import AttachmentResolver from './../services/AttachmentResolver.js' import { extensionHighlight } from '../helpers/mappings.js' -import { createEditor, serializePlainText, loadSyntaxHighlight } from './../EditorFactory.js' +import { createEditor, loadSyntaxHighlight } from './../EditorFactory.js' import { createMarkdownSerializer } from './../extensions/Markdown.js' import markdownit from './../markdownit/index.js' @@ -393,8 +393,8 @@ export default { baseVersionEtag: this.$syncService?.baseVersionEtag, forceRecreate: this.forceRecreate, serialize: this.isRichEditor - ? (content) => createMarkdownSerializer(this.$editor.schema).serialize(content ?? this.$editor.state.doc) - : (content) => serializePlainText(content ?? this.$editor.state.doc), + ? () => createMarkdownSerializer(this.$editor.schema).serialize(this.$editor.state.doc) + : () => this.$editor.state.doc.textContent, getDocumentState: () => getDocumentState(this.$ydoc), }) diff --git a/src/tests/plaintext.spec.js b/src/tests/plaintext.spec.js index fdd68fa0eb4..6fffd404680 100644 --- a/src/tests/plaintext.spec.js +++ b/src/tests/plaintext.spec.js @@ -1,4 +1,4 @@ -import { createEditor, serializePlainText } from './../EditorFactory'; +import { createEditor } from './../EditorFactory'; import spec from "./fixtures/spec" import xssFuzzVectors from './fixtures/xssFuzzVectors'; @@ -17,7 +17,7 @@ const plaintextThroughEditor = (markdown) => { enableRichEditing: false }) tiptap.commands.setContent(content) - return serializePlainText(tiptap.state.doc) || 'failed' + return tiptap.state.doc.textContent || 'failed' } describe('commonmark as plaintext', () => { From cb72b038da266ede245bbc85537e9f76626960f2 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 16 May 2024 23:07:16 +0200 Subject: [PATCH 04/17] enh(serialize): also use markdown approach for plaintext We simply overwrite the `toMarkdown` function and then rely on that. Signed-off-by: Max --- src/EditorFactory.js | 12 ++++++++---- src/components/Editor.vue | 6 ++---- src/extensions/Markdown.js | 7 ++++++- src/nodes/PlainTextDocument.js | 1 - src/nodes/PlainTextLowlight.js | 17 +++++++++++++++++ src/tests/plaintext.spec.js | 3 ++- 6 files changed, 35 insertions(+), 11 deletions(-) create mode 100644 src/nodes/PlainTextLowlight.js diff --git a/src/EditorFactory.js b/src/EditorFactory.js index 42c3c3088db..7ce74806200 100644 --- a/src/EditorFactory.js +++ b/src/EditorFactory.js @@ -27,9 +27,8 @@ import { lowlight } from 'lowlight/lib/core.js' import hljs from 'highlight.js/lib/core' import { logger } from './helpers/logger.js' -import { FocusTrap, Mention, PlainText, RichText } from './extensions/index.js' -// eslint-disable-next-line import/no-named-as-default -import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight' +import { FocusTrap, Mention, PlainText, RichText, Markdown } from './extensions/index.js' +import { PlainTextLowlight } from './nodes/PlainTextLowlight.js' export const loadSyntaxHighlight = async (language) => { const list = hljs.listLanguages() @@ -66,7 +65,12 @@ export const createEditor = ({ language, onCreate = () => {}, onUpdate = () => { FocusTrap, ] } else { - defaultExtensions = [PlainText, CodeBlockLowlight.configure({ lowlight, defaultLanguage: language })] + defaultExtensions = [ + Markdown, + PlainText, + PlainTextLowlight + .configure({ lowlight, defaultLanguage: language }), + ] } return new Editor({ onCreate, diff --git a/src/components/Editor.vue b/src/components/Editor.vue index c87e14a53d1..33e9652b00d 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -115,7 +115,7 @@ import createSyncServiceProvider from './../services/SyncServiceProvider.js' import AttachmentResolver from './../services/AttachmentResolver.js' import { extensionHighlight } from '../helpers/mappings.js' import { createEditor, loadSyntaxHighlight } from './../EditorFactory.js' -import { createMarkdownSerializer } from './../extensions/Markdown.js' +import { serializeEditorContent } from './../extensions/Markdown.js' import markdownit from './../markdownit/index.js' import { CollaborationCursor } from '../extensions/index.js' @@ -392,9 +392,7 @@ export default { filePath: this.relativePath, baseVersionEtag: this.$syncService?.baseVersionEtag, forceRecreate: this.forceRecreate, - serialize: this.isRichEditor - ? () => createMarkdownSerializer(this.$editor.schema).serialize(this.$editor.state.doc) - : () => this.$editor.state.doc.textContent, + serialize: () => serializeEditorContent(this.$editor), getDocumentState: () => getDocumentState(this.$ydoc), }) diff --git a/src/extensions/Markdown.js b/src/extensions/Markdown.js index 4bce626b90a..0031aabf7b8 100644 --- a/src/extensions/Markdown.js +++ b/src/extensions/Markdown.js @@ -191,5 +191,10 @@ const convertNames = (object) => { ) } -export { createMarkdownSerializer } +function serializeEditorContent({ schema, state }) { + return createMarkdownSerializer(schema) + .serialize(state.doc) +} + +export { serializeEditorContent, createMarkdownSerializer } export default Markdown diff --git a/src/nodes/PlainTextDocument.js b/src/nodes/PlainTextDocument.js index 291c1d31457..6b95559ffda 100644 --- a/src/nodes/PlainTextDocument.js +++ b/src/nodes/PlainTextDocument.js @@ -30,5 +30,4 @@ export default Node.create({ Tab: () => this.editor.commands.insertContent('\t'), } }, - }) diff --git a/src/nodes/PlainTextLowlight.js b/src/nodes/PlainTextLowlight.js new file mode 100644 index 00000000000..48fa2682288 --- /dev/null +++ b/src/nodes/PlainTextLowlight.js @@ -0,0 +1,17 @@ +/** + * SPDX-FileCopyrightText: 2024 Max + * SPDX-License-Identifier: @license AGPL-3.0-or-later + * + */ + +// eslint-disable-next-line import/no-named-as-default +import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight' + +const PlainTextLowlight = CodeBlockLowlight.extend({ + name: 'PlainTextLowlight', + toMarkdown(state, node) { + state.write(node.textContent) + }, +}) + +export { PlainTextLowlight } diff --git a/src/tests/plaintext.spec.js b/src/tests/plaintext.spec.js index 6fffd404680..bc60bc420aa 100644 --- a/src/tests/plaintext.spec.js +++ b/src/tests/plaintext.spec.js @@ -1,4 +1,5 @@ import { createEditor } from './../EditorFactory'; +import { serializeEditorContent } from './../extensions/Markdown.js' import spec from "./fixtures/spec" import xssFuzzVectors from './fixtures/xssFuzzVectors'; @@ -17,7 +18,7 @@ const plaintextThroughEditor = (markdown) => { enableRichEditing: false }) tiptap.commands.setContent(content) - return tiptap.state.doc.textContent || 'failed' + return serializeEditorContent(tiptap) || 'failed' } describe('commonmark as plaintext', () => { From 0c89a2e5b37f5e67a3cb904dcdfced49bf29c78c Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 17 May 2024 12:38:54 +0200 Subject: [PATCH 05/17] enh(Serializer): split from Markdown extension We are now using this for serializing plaintext as well. Signed-off-by: Max --- cypress/e2e/nodes/ListItem.spec.js | 4 +- cypress/e2e/nodes/Preview.spec.js | 6 +- cypress/e2e/nodes/Table.spec.js | 9 +- cypress/e2e/nodes/helpers.js | 7 +- src/EditorFactory.js | 5 +- src/components/Editor.vue | 2 +- .../Editor/MarkdownContentEditor.vue | 4 +- src/extensions/Markdown.js | 101 +----------------- src/extensions/PlainText.js | 4 +- src/extensions/RichText.js | 2 + src/extensions/Serializer.js | 74 +++++++++++++ src/extensions/index.js | 2 + src/helpers/serialize.js | 46 ++++++++ src/tests/extensions/Markdown.spec.js | 96 +++-------------- src/tests/extensions/Serializer.spec.js | 81 ++++++++++++++ src/tests/helpers.js | 11 +- src/tests/markdown.spec.js | 6 +- src/tests/nodes/Preview.spec.js | 4 +- src/tests/nodes/Table.spec.js | 6 +- src/tests/nodes/TaskItem.spec.js | 4 +- src/tests/plaintext.spec.js | 2 +- 21 files changed, 256 insertions(+), 220 deletions(-) create mode 100644 src/extensions/Serializer.js create mode 100644 src/helpers/serialize.js create mode 100644 src/tests/extensions/Serializer.spec.js diff --git a/cypress/e2e/nodes/ListItem.spec.js b/cypress/e2e/nodes/ListItem.spec.js index 3a02f5d9208..3b6fae80393 100644 --- a/cypress/e2e/nodes/ListItem.spec.js +++ b/cypress/e2e/nodes/ListItem.spec.js @@ -5,7 +5,7 @@ import ListItem from '@tiptap/extension-list-item' import TaskList from './../../../src/nodes/TaskList.js' import TaskItem from './../../../src/nodes/TaskItem.js' import BulletList from './../../../src/nodes/BulletList.js' -import Markdown from './../../../src/extensions/Markdown.js' +import Serializer from './../../../src/extensions/Serializer.js' import { createCustomEditor } from './../../support/components.js' import { loadMarkdown, runCommands, expectMarkdown } from './helpers.js' @@ -18,7 +18,7 @@ describe('ListItem extension integrated in the editor', () => { const editor = createCustomEditor({ content: '', extensions: [ - Markdown, + Serializer, BulletList, OrderedList, ListItem, diff --git a/cypress/e2e/nodes/Preview.spec.js b/cypress/e2e/nodes/Preview.spec.js index b6cca10eeae..bed1140a6ea 100644 --- a/cypress/e2e/nodes/Preview.spec.js +++ b/cypress/e2e/nodes/Preview.spec.js @@ -21,7 +21,7 @@ * */ -import Markdown from './../../../src/extensions/Markdown.js' +import Serializer from './../../../src/extensions/Serializer.js' import Preview from './../../../src/nodes/Preview.js' import { Italic, Link } from './../../../src/marks/index.js' import { createCustomEditor } from './../../support/components.js' @@ -36,7 +36,7 @@ describe('Preview extension', { retries: 0 }, () => { const editor = createCustomEditor({ content: '', extensions: [ - Markdown, + Serializer, Preview, Link, Italic, @@ -186,7 +186,7 @@ describe('Markdown tests for Previews in the editor', { retries: 0 }, () => { const editor = createCustomEditor({ content: '', extensions: [ - Markdown, + Serializer, Preview, Link, ], diff --git a/cypress/e2e/nodes/Table.spec.js b/cypress/e2e/nodes/Table.spec.js index ca9550ab972..b168d9be03e 100644 --- a/cypress/e2e/nodes/Table.spec.js +++ b/cypress/e2e/nodes/Table.spec.js @@ -1,10 +1,11 @@ import { findChildren } from './../../../src/helpers/prosemirrorUtils.js' import { initUserAndFiles, randUser } from '../../utils/index.js' import { createCustomEditor } from './../../support/components.js' +import { getMarkdown } from './helpers.js' import markdownit from './../../../src/markdownit/index.js' import EditableTable from './../../../src/nodes/EditableTable.js' -import Markdown, { createMarkdownSerializer } from './../../../src/extensions/Markdown.js' +import { Serializer } from './../../../src/extensions/Serializer.js' // https://github.com/import-js/eslint-plugin-import/issues/1739 /* eslint-disable-next-line import/no-unresolved */ @@ -146,7 +147,7 @@ describe('Table extension integrated in the editor', () => { const editor = createCustomEditor({ content: '', extensions: [ - Markdown, + Serializer, EditableTable, ], }) @@ -198,8 +199,4 @@ describe('Table extension integrated in the editor', () => { expect(getMarkdown().replace(/\n$/, '')).to.equal(markdown) } - const getMarkdown = () => { - const serializer = createMarkdownSerializer(editor.schema) - return serializer.serialize(editor.state.doc) - } }) diff --git a/cypress/e2e/nodes/helpers.js b/cypress/e2e/nodes/helpers.js index 4886dd8243a..4c135b9b64b 100644 --- a/cypress/e2e/nodes/helpers.js +++ b/cypress/e2e/nodes/helpers.js @@ -22,7 +22,7 @@ import markdownit from './../../../src/markdownit/index.js' import { findChildren } from './../../../src/helpers/prosemirrorUtils.js' -import { createMarkdownSerializer } from './../../../src/extensions/Markdown.js' +import { serializeEditorContent } from './../../../src/extensions/Serializer.js' /** * @@ -74,7 +74,6 @@ export function expectMarkdown(editor, markdown) { * * @param editor */ -function getMarkdown(editor) { - const serializer = createMarkdownSerializer(editor.schema) - return serializer.serialize(editor.state.doc) +export function getMarkdown(editor) { + return serializeEditorContent(editor) } diff --git a/src/EditorFactory.js b/src/EditorFactory.js index 7ce74806200..9c2a2a29338 100644 --- a/src/EditorFactory.js +++ b/src/EditorFactory.js @@ -27,7 +27,7 @@ import { lowlight } from 'lowlight/lib/core.js' import hljs from 'highlight.js/lib/core' import { logger } from './helpers/logger.js' -import { FocusTrap, Mention, PlainText, RichText, Markdown } from './extensions/index.js' +import { FocusTrap, Mention, PlainText, RichText } from './extensions/index.js' import { PlainTextLowlight } from './nodes/PlainTextLowlight.js' export const loadSyntaxHighlight = async (language) => { @@ -50,6 +50,7 @@ export const createEditor = ({ language, onCreate = () => {}, onUpdate = () => { let defaultExtensions if (enableRichEditing) { defaultExtensions = [ + FocusTrap, RichText.configure({ relativePath, isEmbedded, @@ -62,11 +63,9 @@ export const createEditor = ({ language, onCreate = () => {}, onUpdate = () => { }), ], }), - FocusTrap, ] } else { defaultExtensions = [ - Markdown, PlainText, PlainTextLowlight .configure({ lowlight, defaultLanguage: language }), diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 33e9652b00d..e079a9141bc 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -115,7 +115,7 @@ import createSyncServiceProvider from './../services/SyncServiceProvider.js' import AttachmentResolver from './../services/AttachmentResolver.js' import { extensionHighlight } from '../helpers/mappings.js' import { createEditor, loadSyntaxHighlight } from './../EditorFactory.js' -import { serializeEditorContent } from './../extensions/Markdown.js' +import { serializeEditorContent } from './../extensions/Serializer.js' import markdownit from './../markdownit/index.js' import { CollaborationCursor } from '../extensions/index.js' diff --git a/src/components/Editor/MarkdownContentEditor.vue b/src/components/Editor/MarkdownContentEditor.vue index 68f95ff3ce6..6abf48d4383 100644 --- a/src/components/Editor/MarkdownContentEditor.vue +++ b/src/components/Editor/MarkdownContentEditor.vue @@ -42,7 +42,7 @@ import { Editor } from '@tiptap/core' import History from '@tiptap/extension-history' import { getCurrentUser } from '@nextcloud/auth' import { ATTACHMENT_RESOLVER, EDITOR, IS_RICH_EDITOR } from '../Editor.provider.js' -import { createMarkdownSerializer } from '../../extensions/Markdown.js' +import { serializeEditorContent } from '../../extensions/Serializer.js' import AttachmentResolver from '../../services/AttachmentResolver.js' import markdownit from '../../markdownit/index.js' import { RichText } from '../../extensions/index.js' @@ -150,7 +150,7 @@ export default { content: this.htmlContent, extensions: this.extensions(), onUpdate: ({ editor }) => { - const markdown = (createMarkdownSerializer(this.$editor.schema)).serialize(editor.state.doc) + const markdown = serializeEditorContent(this.$editor) this.emit('update:content', { json: editor.state.doc, markdown, diff --git a/src/extensions/Markdown.js b/src/extensions/Markdown.js index 0031aabf7b8..fc43d431821 100644 --- a/src/extensions/Markdown.js +++ b/src/extensions/Markdown.js @@ -21,56 +21,21 @@ */ /* - * Tiptap extension to ease customize the serialization to markdown - * - * Most markdown serialization can be handled by `prosemirror-markdown`. - * In order to make it easier to add custom markdown rendering - * this extension will extend the prosemirror schema for nodes and marks - * with a `toMarkdown` specification if that is defined in a tiptap extension. - * - * For nodes `toMarkown` should be function - * that take a serializer state and such a node, and serializes the node. - * - * For marks `toMarkdown` is an object with open and close properties, - * which hold the strings that should appear before and after. - * - * For more details see - * https://github.com/ProseMirror/prosemirror-markdown#class-markdownserializer + * Tiptap extension to allow copy and paste of markdown */ -import { Extension, getExtensionField } from '@tiptap/core' +import { Extension } from '@tiptap/core' import { Plugin, PluginKey } from '@tiptap/pm/state' -import { MarkdownSerializer, defaultMarkdownSerializer } from '@tiptap/pm/markdown' +import { MarkdownSerializer } from '@tiptap/pm/markdown' import { DOMParser } from '@tiptap/pm/model' import markdownit from '../markdownit/index.js' import transformPastedHTML from './transformPastedHTML.js' +import { extractNodesToMarkdown, extractToPlaintext } from '../helpers/serialize.js' const Markdown = Extension.create({ name: 'markdown', - extendMarkSchema(extension) { - const context = { - name: extension.name, - options: extension.options, - storage: extension.storage, - } - return { - toMarkdown: getExtensionField(extension, 'toMarkdown', context), - } - }, - - extendNodeSchema(extension) { - const context = { - name: extension.name, - options: extension.options, - storage: extension.storage, - } - return { - toMarkdown: getExtensionField(extension, 'toMarkdown', context), - } - }, - addProseMirrorPlugins() { let shiftKey = false @@ -127,18 +92,6 @@ const Markdown = Extension.create({ }, }) -const createMarkdownSerializer = ({ nodes, marks }) => { - return { - serializer: new MarkdownSerializer( - extractNodesToMarkdown(nodes), - extractMarksToMarkdown(marks), - ), - serialize(content, options) { - return this.serializer.serialize(content, { ...options, tightLists: true }) - }, - } -} - const clipboardSerializer = ({ nodes, marks }) => { return { serializer: new MarkdownSerializer( @@ -151,50 +104,4 @@ const clipboardSerializer = ({ nodes, marks }) => { } } -const extractToPlaintext = (marks) => { - const blankMark = { open: '', close: '', mixable: true, expelEnclosingWhitespace: true } - const defaultMarks = convertNames(defaultMarkdownSerializer.marks) - const markEntries = Object.entries({ ...defaultMarks, ...marks }) - .map(([name, _mark]) => [name, blankMark]) - - return Object.fromEntries(markEntries) -} - -const extractToMarkdown = (nodesOrMarks) => { - const nodeOrMarkEntries = Object - .entries(nodesOrMarks) - .map(([name, nodeOrMark]) => [name, nodeOrMark.spec.toMarkdown]) - .filter(([, toMarkdown]) => toMarkdown) - - return Object.fromEntries(nodeOrMarkEntries) -} - -const extractNodesToMarkdown = (nodes) => { - const defaultNodes = convertNames(defaultMarkdownSerializer.nodes) - const nodesToMarkdown = extractToMarkdown(nodes) - return { ...defaultNodes, ...nodesToMarkdown } -} - -const extractMarksToMarkdown = (marks) => { - const defaultMarks = convertNames(defaultMarkdownSerializer.marks) - const marksToMarkdown = extractToMarkdown(marks) - return { ...defaultMarks, ...marksToMarkdown } -} - -const convertNames = (object) => { - const convert = (name) => { - return name.replace(/_(\w)/g, (_m, letter) => letter.toUpperCase()) - } - return Object.fromEntries( - Object.entries(object) - .map(([name, value]) => [convert(name), value]), - ) -} - -function serializeEditorContent({ schema, state }) { - return createMarkdownSerializer(schema) - .serialize(state.doc) -} - -export { serializeEditorContent, createMarkdownSerializer } export default Markdown diff --git a/src/extensions/PlainText.js b/src/extensions/PlainText.js index 305f24016b9..d13818593be 100644 --- a/src/extensions/PlainText.js +++ b/src/extensions/PlainText.js @@ -22,9 +22,10 @@ import { Extension } from '@tiptap/core' +import PlainTextDocument from './../nodes/PlainTextDocument.js' +import Serializer from './../extensions/Serializer.js' /* eslint-disable import/no-named-as-default */ import Text from '@tiptap/extension-text' -import PlainTextDocument from './../nodes/PlainTextDocument.js' export default Extension.create({ name: 'PlainText', @@ -32,6 +33,7 @@ export default Extension.create({ addExtensions() { return [ PlainTextDocument, + Serializer, Text, ] }, diff --git a/src/extensions/RichText.js b/src/extensions/RichText.js index 1ab826202e6..651865de9fd 100644 --- a/src/extensions/RichText.js +++ b/src/extensions/RichText.js @@ -52,6 +52,7 @@ import OrderedList from '@tiptap/extension-ordered-list' import Paragraph from './../nodes/Paragraph.js' import Placeholder from '@tiptap/extension-placeholder' import Preview from './../nodes/Preview.js' +import Serializer from './../extensions/Serializer.js' import Table from './../nodes/Table.js' import TaskItem from './../nodes/TaskItem.js' import TaskList from './../nodes/TaskList.js' @@ -80,6 +81,7 @@ export default Extension.create({ addExtensions() { const defaultExtensions = [ this.options.editing ? Markdown : null, + this.options.editing ? Serializer : null, Document, Text, Paragraph, diff --git a/src/extensions/Serializer.js b/src/extensions/Serializer.js new file mode 100644 index 00000000000..2e6273d889a --- /dev/null +++ b/src/extensions/Serializer.js @@ -0,0 +1,74 @@ +/** + * SPDX-FileCopyrightText: 2024 Max + * SPDX-License-Identifier: @license AGPL-3.0-or-later + * + */ + +/* + * Tiptap extension to ease customize the serialization to markdown + * + * Most markdown serialization can be handled by `prosemirror-markdown`. + * In order to make it easier to add custom markdown rendering + * this extension will extend the prosemirror schema for nodes and marks + * with a `toMarkdown` specification if that is defined in a tiptap extension. + * + * For nodes `toMarkown` should be function + * that take a serializer state and such a node, and serializes the node. + * + * For marks `toMarkdown` is an object with open and close properties, + * which hold the strings that should appear before and after. + * + * For more details see + * https://github.com/ProseMirror/prosemirror-markdown#class-markdownserializer + */ + +import { Extension, getExtensionField } from '@tiptap/core' +import { MarkdownSerializer } from '@tiptap/pm/markdown' +import { extractNodesToMarkdown, extractMarksToMarkdown } from '../helpers/serialize.js' + +export function serializeEditorContent({ schema, state }) { + return createMarkdownSerializer(schema) + .serialize(state.doc) +} + +export const Serializer = Extension.create({ + + name: 'serializer', + + extendMarkSchema(extension) { + const context = { + name: extension.name, + options: extension.options, + storage: extension.storage, + } + return { + toMarkdown: getExtensionField(extension, 'toMarkdown', context), + } + }, + + extendNodeSchema(extension) { + const context = { + name: extension.name, + options: extension.options, + storage: extension.storage, + } + return { + toMarkdown: getExtensionField(extension, 'toMarkdown', context), + } + }, + +}) + +export const createMarkdownSerializer = ({ nodes, marks }) => { + return { + serializer: new MarkdownSerializer( + extractNodesToMarkdown(nodes), + extractMarksToMarkdown(marks), + ), + serialize(content, options) { + return this.serializer.serialize(content, { ...options, tightLists: true }) + }, + } +} + +export default Serializer diff --git a/src/extensions/index.js b/src/extensions/index.js index 0127386b12e..45fb0b48be0 100644 --- a/src/extensions/index.js +++ b/src/extensions/index.js @@ -30,6 +30,7 @@ import PlainText from './PlainText.js' import RichText from './RichText.js' import KeepSyntax from './KeepSyntax.js' import Mention from './Mention.js' +import Serializer from './Serializer.js' export { CollaborationCursor, @@ -42,4 +43,5 @@ export { RichText, KeepSyntax, Mention, + Serializer, } diff --git a/src/helpers/serialize.js b/src/helpers/serialize.js new file mode 100644 index 00000000000..e1109735c3b --- /dev/null +++ b/src/helpers/serialize.js @@ -0,0 +1,46 @@ +/** + * SPDX-FileCopyrightText: 2024 Max + * SPDX-License-Identifier: @license AGPL-3.0-or-later + * + */ + +import { defaultMarkdownSerializer } from '@tiptap/pm/markdown' + +export const extractNodesToMarkdown = (nodes) => { + const defaultNodes = convertNames(defaultMarkdownSerializer.nodes) + const nodesToMarkdown = extractToMarkdown(nodes) + return { ...defaultNodes, ...nodesToMarkdown } +} + +export const extractMarksToMarkdown = (marks) => { + const defaultMarks = convertNames(defaultMarkdownSerializer.marks) + const marksToMarkdown = extractToMarkdown(marks) + return { ...defaultMarks, ...marksToMarkdown } +} + +export const extractToPlaintext = (marks) => { + const blankMark = { open: '', close: '', mixable: true, expelEnclosingWhitespace: true } + const defaultMarks = convertNames(defaultMarkdownSerializer.marks) + const markEntries = Object.entries({ ...defaultMarks, ...marks }) + .map(([name, _mark]) => [name, blankMark]) + return Object.fromEntries(markEntries) +} + +const convertNames = (object) => { + const convert = (name) => { + return name.replace(/_(\w)/g, (_m, letter) => letter.toUpperCase()) + } + return Object.fromEntries( + Object.entries(object) + .map(([name, value]) => [convert(name), value]), + ) +} + +const extractToMarkdown = (nodesOrMarks) => { + const nodeOrMarkEntries = Object + .entries(nodesOrMarks) + .map(([name, nodeOrMark]) => [name, nodeOrMark.spec.toMarkdown]) + .filter(([, toMarkdown]) => toMarkdown) + + return Object.fromEntries(nodeOrMarkEntries) +} diff --git a/src/tests/extensions/Markdown.spec.js b/src/tests/extensions/Markdown.spec.js index 6308348d32c..26a1f479f5d 100644 --- a/src/tests/extensions/Markdown.spec.js +++ b/src/tests/extensions/Markdown.spec.js @@ -1,89 +1,23 @@ -import { Markdown } from './../../extensions/index.js' -import { createMarkdownSerializer } from './../../extensions/Markdown.js' -import CodeBlock from '@tiptap/extension-code-block' -import Blockquote from '@tiptap/extension-blockquote' -import Image from './../../nodes/Image.js' -import ImageInline from './../../nodes/ImageInline.js' +/** + * SPDX-FileCopyrightText: 2024 Max + * SPDX-License-Identifier: @license AGPL-3.0-or-later + * + */ + +import { Serializer, Markdown } from './../../extensions/index.js' import TaskList from './../../nodes/TaskList.js' import TaskItem from './../../nodes/TaskItem.js' +import CodeBlock from '@tiptap/extension-code-block' +import Blockquote from '@tiptap/extension-blockquote' import { Italic, Strong, Underline, Link} from './../../marks/index.js' -import TiptapImage from '@tiptap/extension-image' -import { getExtensionField } from '@tiptap/core' import { __serializeForClipboard as serializeForClipboard } from '@tiptap/pm/view' import { createCustomEditor } from '../helpers.js' -describe('Markdown extension unit', () => { - it('has a config', () => { - expect(Markdown.config.name).toBe('markdown') - }) - - it('exposes toMarkdown function in Prosemirror', () => { - const extend = getExtensionField(Markdown, 'extendMarkSchema', Markdown) - expect(extend(Underline).toMarkdown).toBeDefined() - }) - - it('makes toMarkdown available in prose mirror schema', () => { - const editor = createCustomEditor({ - extensions: [Markdown, Underline], - }) - const serializer = createMarkdownSerializer(editor.schema) - const underline = serializer.serializer.marks.underline - expect(underline).toEqual(Underline.config.toMarkdown) - const listItem = serializer.serializer.nodes.listItem - expect(typeof listItem).toBe('function') - }) -}) - describe('Markdown extension integrated in the editor', () => { - it('serializes marks according to their spec', () => { - const editor = createCustomEditor({ - content: '

Test

', - extensions: [Markdown, Underline], - }) - const serializer = createMarkdownSerializer(editor.schema) - expect(serializer.serialize(editor.state.doc)).toBe('__Test__') - }) - - it('serializes nodes according to their spec', () => { - const editor = createCustomEditor({ - content: '

  • Hello

', - extensions: [Markdown, TaskList, TaskItem], - }) - const serializer = createMarkdownSerializer(editor.schema) - expect(serializer.serialize(editor.state.doc)).toBe('\n- [ ] Hello') - }) - - it('serializes images with the default prosemirror way', () => { - const editor = createCustomEditor({ - content: '

Hello

', - extensions: [Markdown, TiptapImage.configure({ inline: true })], - }) - const serializer = createMarkdownSerializer(editor.schema) - expect(serializer.serialize(editor.state.doc)).toBe('![Hello](test)') - }) - - it('serializes block images with the default prosemirror way', () => { - const editor = createCustomEditor({ - content: '
Hello

hello

', - extensions: [Markdown, Image, ImageInline], - }) - const serializer = createMarkdownSerializer(editor.schema) - expect(serializer.serialize(editor.state.doc)).toBe('![Hello](test)\n\nhello') - }) - - it('serializes inline images with the default prosemirror way', () => { - const editor = createCustomEditor({ - content: '

inline image Hello inside text

', - extensions: [Markdown, Image, ImageInline], - }) - const serializer = createMarkdownSerializer(editor.schema) - expect(serializer.serialize(editor.state.doc)).toBe('inline image ![Hello](test) inside text') - }) - it('copies task lists to plaintext like markdown', () => { const editor = createCustomEditor({ content: '

  • Hello

', - extensions: [Markdown, TaskList, TaskItem], + extensions: [Markdown, Serializer, TaskList, TaskItem], }) const text = copyEditorContent(editor) expect(text).toBe('\n- [ ] Hello') @@ -92,7 +26,7 @@ describe('Markdown extension integrated in the editor', () => { it('copies code block content to plaintext according to their spec', () => { const editor = createCustomEditor({ content: '
Hello
', - extensions: [Markdown, CodeBlock], + extensions: [Markdown, Serializer, CodeBlock], }) const text = copyEditorContent(editor) expect(text).toBe('Hello') @@ -101,7 +35,7 @@ describe('Markdown extension integrated in the editor', () => { it('copies nested task list nodes to markdown like syntax', () => { const editor = createCustomEditor({ content: '

  • Hello
', - extensions: [Markdown, Blockquote, TaskList, TaskItem], + extensions: [Markdown, Serializer, Blockquote, TaskList, TaskItem], }) const text = copyEditorContent(editor) expect(text).toBe('\n- [ ] Hello') @@ -110,7 +44,7 @@ describe('Markdown extension integrated in the editor', () => { it('copies address from blockquote to markdown', () => { const editor = createCustomEditor({ content: '

Hermannsreute 44A

', - extensions: [Markdown, Blockquote], + extensions: [Markdown, Serializer, Blockquote], }) const text = copyEditorContent(editor) expect(text).toBe('Hermannsreute 44A') @@ -128,7 +62,7 @@ describe('Markdown extension integrated in the editor', () => { it('strips bold, italic, and other marks from paragraph', () => { const editor = createCustomEditor({ content: '

Hello

lonely world

', - extensions: [Markdown, Italic, Strong, Underline], + extensions: [Markdown, Serializer, Italic, Strong, Underline], }) const text = copyEditorContent(editor) expect(text).toBe('Hello\n\nlonely world') @@ -137,7 +71,7 @@ describe('Markdown extension integrated in the editor', () => { it('strips href and link formatting from email address', () => { const editor = createCustomEditor({ content: '

Hello

example@example.com

', - extensions: [Markdown, Link], + extensions: [Markdown, Serializer, Link], }) const text = copyEditorContent(editor) expect(text).toBe('Hello\n\nexample@example.com') diff --git a/src/tests/extensions/Serializer.spec.js b/src/tests/extensions/Serializer.spec.js new file mode 100644 index 00000000000..f4f400b5fd9 --- /dev/null +++ b/src/tests/extensions/Serializer.spec.js @@ -0,0 +1,81 @@ +import { Serializer } from './../../extensions/index.js' +import { + createMarkdownSerializer, + serializeEditorContent +} from './../../extensions/Serializer.js' +import Image from './../../nodes/Image.js' +import ImageInline from './../../nodes/ImageInline.js' +import TaskList from './../../nodes/TaskList.js' +import TaskItem from './../../nodes/TaskItem.js' +import { Underline } from './../../marks/index.js' +import TiptapImage from '@tiptap/extension-image' +import { getExtensionField } from '@tiptap/core' +import { createCustomEditor } from '../helpers.js' + +describe('Serializer extension unit', () => { + it('has a config', () => { + expect(Serializer.config.name).toBe('serializer') + }) + + it('exposes toMarkdown function in Prosemirror', () => { + const extend = getExtensionField(Serializer, 'extendMarkSchema', Serializer) + expect(extend(Underline).toMarkdown).toBeDefined() + }) + + it('makes toMarkdown available in prose mirror schema', () => { + const editor = createCustomEditor({ + extensions: [Serializer, Underline], + }) + const serializer = createMarkdownSerializer(editor.schema) + const underline = serializer.serializer.marks.underline + expect(underline).toEqual(Underline.config.toMarkdown) + const listItem = serializer.serializer.nodes.listItem + expect(typeof listItem).toBe('function') + }) +}) + +describe('Markdown extension integrated in the editor', () => { + it('serializes marks according to their spec', () => { + const editor = createCustomEditor({ + content: '

Test

', + extensions: [Serializer, Underline], + }) + expect(serializeEditorContent(editor)).toBe('__Test__') + }) + + it('serializes nodes according to their spec', () => { + const editor = createCustomEditor({ + content: '

  • Hello

', + extensions: [Serializer, TaskList, TaskItem], + }) + expect(serializeEditorContent(editor)) + .toBe('\n- [ ] Hello') + }) + + it('serializes images with the default prosemirror way', () => { + const editor = createCustomEditor({ + content: '

Hello

', + extensions: [Serializer, TiptapImage.configure({ inline: true })], + }) + expect(serializeEditorContent(editor)) + .toBe('![Hello](test)') + }) + + it('serializes block images with the default prosemirror way', () => { + const editor = createCustomEditor({ + content: '
Hello

hello

', + extensions: [Serializer, Image, ImageInline], + }) + expect(serializeEditorContent(editor)) + .toBe('![Hello](test)\n\nhello') + }) + + it('serializes inline images with the default prosemirror way', () => { + const editor = createCustomEditor({ + content: '

inline image Hello inside text

', + extensions: [Serializer, Image, ImageInline], + }) + expect(serializeEditorContent(editor)) + .toBe('inline image ![Hello](test) inside text') + }) +}) diff --git a/src/tests/helpers.js b/src/tests/helpers.js index 401dec40565..1f6f563b603 100644 --- a/src/tests/helpers.js +++ b/src/tests/helpers.js @@ -1,5 +1,5 @@ -import { createMarkdownSerializer } from '../extensions/Markdown' import { Editor } from '@tiptap/core' +import { serializeEditorContent } from '../extensions/Serializer' import Document from '@tiptap/extension-document' import Paragraph from '../nodes/Paragraph' @@ -31,8 +31,7 @@ export function markdownThroughEditor(markdown) { enableRichEditing: true }) tiptap.commands.setContent(markdownit.render(markdown)) - const serializer = createMarkdownSerializer(tiptap.schema) - return serializer.serialize(tiptap.state.doc) + return serializeEditorContent(tiptap) } /** @@ -46,8 +45,7 @@ export function markdownThroughEditorHtml(html) { enableRichEditing: true }) tiptap.commands.setContent(html) - const serializer = createMarkdownSerializer(tiptap.schema) - return serializer.serialize(tiptap.state.doc) + return serializeEditorContent(tiptap) } /** @@ -61,6 +59,5 @@ export function markdownFromPaste(html) { enableRichEditing: true }) tiptap.commands.insertContent(html) - const serializer = createMarkdownSerializer(tiptap.schema) - return serializer.serialize(tiptap.state.doc) + return serializeEditorContent(tiptap) } diff --git a/src/tests/markdown.spec.js b/src/tests/markdown.spec.js index d473116679b..18b1a571a00 100644 --- a/src/tests/markdown.spec.js +++ b/src/tests/markdown.spec.js @@ -5,7 +5,7 @@ import { markdownThroughEditorHtml, markdownFromPaste } from './helpers.js' -import { createMarkdownSerializer } from "../extensions/Markdown"; +import { serializeEditorContent } from "../extensions/Serializer"; import { createEditor } from "../EditorFactory"; /* @@ -205,8 +205,6 @@ describe('Trailing nodes', () => { const jsonAfter = tiptap.getJSON() expect(jsonAfter).toStrictEqual(jsonBefore) - const serializer = createMarkdownSerializer(tiptap.schema) - const md = serializer.serialize(tiptap.state.doc) - expect(md).toBe(source) + expect(serializeEditorContent(tiptap)).toBe(source) }) }) diff --git a/src/tests/nodes/Preview.spec.js b/src/tests/nodes/Preview.spec.js index 4b0107dc02b..1ac6cf167f4 100644 --- a/src/tests/nodes/Preview.spec.js +++ b/src/tests/nodes/Preview.spec.js @@ -1,5 +1,5 @@ import Preview from './../../nodes/Preview' -import Markdown from './../../extensions/Markdown' +import Serializer from './../../extensions/Serializer' import Link from './../../marks/Link' import { getExtensionField } from '@tiptap/core' import { createCustomEditor, markdownThroughEditor, markdownThroughEditorHtml } from '../helpers' @@ -43,6 +43,6 @@ describe('Preview extension', () => { function createEditorWithPreview() { return createCustomEditor({ - extensions: [Markdown, Preview, Link] + extensions: [Serializer, Preview, Link] }) } diff --git a/src/tests/nodes/Table.spec.js b/src/tests/nodes/Table.spec.js index c152ba38440..b42297c511b 100644 --- a/src/tests/nodes/Table.spec.js +++ b/src/tests/nodes/Table.spec.js @@ -1,5 +1,5 @@ import { createEditor } from '../../EditorFactory' -import { createMarkdownSerializer } from '../../extensions/Markdown' +import { serializeEditorContent } from '../../extensions/Serializer' import { builders } from 'prosemirror-test-builder' import markdownit from '../../markdownit' @@ -63,9 +63,7 @@ describe('Table', () => { test('serialize from editor', () => { const tiptap = editorWithContent(markdownit.render(input)) - const serializer = createMarkdownSerializer(tiptap.schema) - - expect(serializer.serialize(tiptap.state.doc)).toBe(input) + expect(serializeEditorContent(tiptap)).toBe(input) }) }) diff --git a/src/tests/nodes/TaskItem.spec.js b/src/tests/nodes/TaskItem.spec.js index abc0a6d7b6e..6b1f6b89881 100644 --- a/src/tests/nodes/TaskItem.spec.js +++ b/src/tests/nodes/TaskItem.spec.js @@ -1,6 +1,6 @@ import TaskList from './../../nodes/TaskList' import TaskItem from './../../nodes/TaskItem' -import Markdown from './../../extensions/Markdown' +import Serializer from './../../extensions/Serializer' import { getExtensionField } from '@tiptap/core' import { createCustomEditor, markdownThroughEditor, markdownThroughEditorHtml } from '../helpers' @@ -12,7 +12,7 @@ describe('TaskItem extension', () => { it('exposes the toMarkdown function in the prosemirror schema', () => { const editor = createCustomEditor({ - extensions: [Markdown, TaskList, TaskItem] + extensions: [Serializer, TaskList, TaskItem] }) const taskItem = editor.schema.nodes.taskItem expect(taskItem.spec.toMarkdown).toBeDefined() diff --git a/src/tests/plaintext.spec.js b/src/tests/plaintext.spec.js index bc60bc420aa..f38982c03b2 100644 --- a/src/tests/plaintext.spec.js +++ b/src/tests/plaintext.spec.js @@ -1,5 +1,5 @@ import { createEditor } from './../EditorFactory'; -import { serializeEditorContent } from './../extensions/Markdown.js' +import { serializeEditorContent } from './../extensions/Serializer.js' import spec from "./fixtures/spec" import xssFuzzVectors from './fixtures/xssFuzzVectors'; From d4788785602552e96ed45805fd6fe44f33ded19b Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 18 May 2024 11:36:10 +0200 Subject: [PATCH 06/17] refactor(Editor): move initialization into onOpened We do need the session for initialization but we do not need the `documentSource` or `documentState` anymore Signed-off-by: Max --- src/components/Editor.vue | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/src/components/Editor.vue b/src/components/Editor.vue index e079a9141bc..70931672a69 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -495,32 +495,17 @@ export default { this.lock = this.$syncService.lock localStorage.setItem('nick', this.currentSession.guestName) this.$attachmentResolver = new AttachmentResolver({ - session: this.currentSession, + session, user: getCurrentUser(), shareToken: this.shareToken, currentDirectory: this.currentDirectory, }) - }, - - onLoaded({ documentSource, documentState }) { - if (documentState) { - applyDocumentState(this.$ydoc, documentState, this.$providers[0]) - // distribute additional state that may exist locally - const updateMessage = getUpdateMessage(this.$ydoc, documentState) - if (updateMessage) { - logger.debug('onLoaded: Pushing local changes to server') - this.$queue.push(updateMessage) - } - } else { - this.setInitialYjsState(documentSource, { isRichEditor: this.isRichEditor }) - } this.hasConnectionIssue = false const language = extensionHighlight[this.fileExtension] || this.fileExtension; (this.isRichEditor ? Promise.resolve() : loadSyntaxHighlight(language)) .then(() => { - const session = this.currentSession if (!this.$editor) { this.$editor = createEditor({ language, @@ -565,7 +550,20 @@ export default { } }) + }, + onLoaded({ documentSource, documentState }) { + if (documentState) { + applyDocumentState(this.$ydoc, documentState, this.$providers[0]) + // distribute additional state that may exist locally + const updateMessage = getUpdateMessage(this.$ydoc, documentState) + if (updateMessage) { + logger.debug('onLoaded: Pushing local changes to server') + this.$queue.push(updateMessage) + } + } else { + this.setInitialYjsState(documentSource, { isRichEditor: this.isRichEditor }) + } }, onChange({ document, sessions }) { From 00a82fbc086bac4fcfbde95be6ed481db1e0d01c Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 18 May 2024 11:45:24 +0200 Subject: [PATCH 07/17] refactor(Serializer): make _createMarkdownSerializer internal Signed-off-by: Max --- src/extensions/Serializer.js | 20 ++++++++++---------- src/tests/extensions/Serializer.spec.js | 8 ++++---- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/extensions/Serializer.js b/src/extensions/Serializer.js index 2e6273d889a..2c0484bfbfd 100644 --- a/src/extensions/Serializer.js +++ b/src/extensions/Serializer.js @@ -27,8 +27,8 @@ import { MarkdownSerializer } from '@tiptap/pm/markdown' import { extractNodesToMarkdown, extractMarksToMarkdown } from '../helpers/serialize.js' export function serializeEditorContent({ schema, state }) { - return createMarkdownSerializer(schema) - .serialize(state.doc) + return _createMarkdownSerializer(schema) + .serialize(state.doc, { tightLists: true }) } export const Serializer = Extension.create({ @@ -59,16 +59,16 @@ export const Serializer = Extension.create({ }) -export const createMarkdownSerializer = ({ nodes, marks }) => { - return { - serializer: new MarkdownSerializer( +/* + * Create the markdown serializer. + * + * Only exported for tests, + */ +export const _createMarkdownSerializer = ({ nodes, marks }) => { + return new MarkdownSerializer( extractNodesToMarkdown(nodes), extractMarksToMarkdown(marks), - ), - serialize(content, options) { - return this.serializer.serialize(content, { ...options, tightLists: true }) - }, - } + ) } export default Serializer diff --git a/src/tests/extensions/Serializer.spec.js b/src/tests/extensions/Serializer.spec.js index f4f400b5fd9..7ec534427c6 100644 --- a/src/tests/extensions/Serializer.spec.js +++ b/src/tests/extensions/Serializer.spec.js @@ -1,6 +1,6 @@ import { Serializer } from './../../extensions/index.js' import { - createMarkdownSerializer, + _createMarkdownSerializer, serializeEditorContent } from './../../extensions/Serializer.js' import Image from './../../nodes/Image.js' @@ -26,10 +26,10 @@ describe('Serializer extension unit', () => { const editor = createCustomEditor({ extensions: [Serializer, Underline], }) - const serializer = createMarkdownSerializer(editor.schema) - const underline = serializer.serializer.marks.underline + const serializer = _createMarkdownSerializer(editor.schema) + const underline = serializer.marks.underline expect(underline).toEqual(Underline.config.toMarkdown) - const listItem = serializer.serializer.nodes.listItem + const listItem = serializer.nodes.listItem expect(typeof listItem).toBe('function') }) }) From 43355d2c2f9ceca81cfd43b44599204241f08b09 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 18 May 2024 11:49:54 +0200 Subject: [PATCH 08/17] fix(lint): only default export Serializer Signed-off-by: Max --- cypress/e2e/nodes/Table.spec.js | 2 +- src/extensions/Serializer.js | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cypress/e2e/nodes/Table.spec.js b/cypress/e2e/nodes/Table.spec.js index b168d9be03e..0c8355c0036 100644 --- a/cypress/e2e/nodes/Table.spec.js +++ b/cypress/e2e/nodes/Table.spec.js @@ -5,7 +5,7 @@ import { getMarkdown } from './helpers.js' import markdownit from './../../../src/markdownit/index.js' import EditableTable from './../../../src/nodes/EditableTable.js' -import { Serializer } from './../../../src/extensions/Serializer.js' +import { Serializer } from './../../../src/extensions/index.js' // https://github.com/import-js/eslint-plugin-import/issues/1739 /* eslint-disable-next-line import/no-unresolved */ diff --git a/src/extensions/Serializer.js b/src/extensions/Serializer.js index 2c0484bfbfd..c767d6d6ae0 100644 --- a/src/extensions/Serializer.js +++ b/src/extensions/Serializer.js @@ -31,7 +31,7 @@ export function serializeEditorContent({ schema, state }) { .serialize(state.doc, { tightLists: true }) } -export const Serializer = Extension.create({ +const Serializer = Extension.create({ name: 'serializer', @@ -66,9 +66,9 @@ export const Serializer = Extension.create({ */ export const _createMarkdownSerializer = ({ nodes, marks }) => { return new MarkdownSerializer( - extractNodesToMarkdown(nodes), - extractMarksToMarkdown(marks), - ) + extractNodesToMarkdown(nodes), + extractMarksToMarkdown(marks), + ) } export default Serializer From 3df029401e304bbd1590c2300e437483da2c48bd Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 18 May 2024 14:35:19 +0200 Subject: [PATCH 09/17] fix(lint): fix all warnings * Use const arrow functions when no doc comment is needed. * Use the tiptap api to inspect editor state rather than reaching deep into the editor state. Signed-off-by: Max --- cypress/e2e/directediting.spec.js | 2 +- cypress/e2e/nodes/Preview.spec.js | 48 +++++++++++++------------------ cypress/e2e/nodes/helpers.js | 32 ++++----------------- src/extensions/Serializer.js | 2 +- 4 files changed, 27 insertions(+), 57 deletions(-) diff --git a/cypress/e2e/directediting.spec.js b/cypress/e2e/directediting.spec.js index 9915ad8064d..94f0af9ca1c 100644 --- a/cypress/e2e/directediting.spec.js +++ b/cypress/e2e/directediting.spec.js @@ -2,7 +2,7 @@ import { initUserAndFiles, randUser } from '../utils/index.js' const user = randUser() -function enterContentAndClose() { +const enterContentAndClose = () => { cy.intercept({ method: 'POST', url: '**/session/*/close' }).as('closeRequest') cy.intercept({ method: 'POST', url: '**/session/*/push' }).as('push') cy.intercept({ method: 'POST', url: '**/session/*/sync' }).as('sync') diff --git a/cypress/e2e/nodes/Preview.spec.js b/cypress/e2e/nodes/Preview.spec.js index bed1140a6ea..28d2ba4625e 100644 --- a/cypress/e2e/nodes/Preview.spec.js +++ b/cypress/e2e/nodes/Preview.spec.js @@ -26,6 +26,7 @@ import Preview from './../../../src/nodes/Preview.js' import { Italic, Link } from './../../../src/marks/index.js' import { createCustomEditor } from './../../support/components.js' import { loadMarkdown, runCommands, expectMarkdown } from './helpers.js' +import { isNodeActive, getMarkAttributes, getNodeAttributes } from '@tiptap/core' // https://github.com/import-js/eslint-plugin-import/issues/1739 /* eslint-disable-next-line import/no-unresolved */ @@ -133,51 +134,42 @@ describe('Preview extension', { retries: 0 }, () => { prepareEditor('[link text](https://nextcloud.com)\n') editor.commands.setPreview() editor.commands.unsetPreview() - expect(getParentNode().type.name).to.equal('paragraph') - }) - - it('includes a link', () => { - prepareEditor('[link text](https://nextcloud.com)\n') - editor.commands.setPreview() - editor.commands.unsetPreview() - expect(getMark().attrs.href).to.equal('https://nextcloud.com') + expectParagraphWithLink() }) }) /** - * Expect a preview in the editor. + * Expect a preview at the current position. */ function expectPreview() { - expect(getParentNode().type.name).to.equal('preview') - expect(getParentNode().attrs.href).to.equal('https://nextcloud.com') - expect(getMark().attrs.href).to.equal('https://nextcloud.com') - } - - /** - * - */ - function getParentNode() { - const { state: { selection } } = editor - return selection.$head.parent + expect(isNodeActive(editor.state, 'paragraph')) + .to.be.false + expect(getNodeAttributes(editor.state, 'preview')) + .to.include({ href: 'https://nextcloud.com' }) + expect(getMarkAttributes(editor.state, 'link')) + .to.include({ href: 'https://nextcloud.com' }) } /** - * + * Expect a paragraph with a link at the current position. */ - function getMark() { - const { state: { selection } } = editor - console.info(selection.$head) - return selection.$head.nodeAfter.marks[0] + function expectParagraphWithLink() { + expect(isNodeActive(editor.state, 'preview')) + .to.be.false + expect(isNodeActive(editor.state, 'paragraph')) + .to.be.true + expect(getMarkAttributes(editor.state, 'link')) + .to.include({ href: 'https://nextcloud.com' }) } /** - * - * @param input + * Load input and position the cursor inside. + * @param { string } input - markdown to load */ function prepareEditor(input) { loadMarkdown(editor, input) - editor.commands.setTextSelection(1) + editor.commands.setTextSelection(2) } }) diff --git a/cypress/e2e/nodes/helpers.js b/cypress/e2e/nodes/helpers.js index 4c135b9b64b..cc91b7fc220 100644 --- a/cypress/e2e/nodes/helpers.js +++ b/cypress/e2e/nodes/helpers.js @@ -24,21 +24,12 @@ import markdownit from './../../../src/markdownit/index.js' import { findChildren } from './../../../src/helpers/prosemirrorUtils.js' import { serializeEditorContent } from './../../../src/extensions/Serializer.js' -/** - * - * @param editor - * @param markdown - */ -export function loadMarkdown(editor, markdown) { +export const loadMarkdown = (editor, markdown) => { const stripped = markdown.replace(/\t*/g, '') editor.commands.setContent(markdownit.render(stripped)) } -/** - * - * @param editor - */ -export function runCommands(editor) { +export const runCommands = (editor) => { let found while ((found = findCommand(editor))) { const { node, pos } = found @@ -49,31 +40,18 @@ export function runCommands(editor) { } } -/** - * - * @param editor - */ -function findCommand(editor) { +const findCommand = (editor) => { const doc = editor.state.doc return findChildren(doc, child => { return child.isText && Object.prototype.hasOwnProperty.call(editor.commands, child.text) })[0] } -/** - * - * @param editor - * @param markdown - */ -export function expectMarkdown(editor, markdown) { +export const expectMarkdown = (editor, markdown) => { const stripped = markdown.replace(/\t*/g, '') expect(getMarkdown(editor)).to.equal(stripped) } -/** - * - * @param editor - */ -export function getMarkdown(editor) { +export const getMarkdown = (editor) => { return serializeEditorContent(editor) } diff --git a/src/extensions/Serializer.js b/src/extensions/Serializer.js index c767d6d6ae0..b2a44dfd604 100644 --- a/src/extensions/Serializer.js +++ b/src/extensions/Serializer.js @@ -26,7 +26,7 @@ import { Extension, getExtensionField } from '@tiptap/core' import { MarkdownSerializer } from '@tiptap/pm/markdown' import { extractNodesToMarkdown, extractMarksToMarkdown } from '../helpers/serialize.js' -export function serializeEditorContent({ schema, state }) { +export const serializeEditorContent = ({ schema, state }) => { return _createMarkdownSerializer(schema) .serialize(state.doc, { tightLists: true }) } From 3559f13dec405a2708f811d09326aa7bc1dcf64a Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 18 May 2024 14:47:39 +0200 Subject: [PATCH 10/17] refactor(syncService): do not expose serialize() Keep as `#getContent` internally. Use `serializeEditorContent(editor)` outside of the `SyncService`. Signed-off-by: Max --- src/components/Editor.vue | 16 +++++++--------- src/services/SyncService.js | 9 +++------ 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 70931672a69..7c4a0032844 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -516,10 +516,8 @@ export default { }, onUpdate: ({ editor }) => { // this.debugContent(editor) - const proseMirrorMarkdown = this.$syncService.serialize(editor.state.doc) - this.emit('update:content', { - markdown: proseMirrorMarkdown, - }) + const markdown = serializeEditorContent(editor) + this.emit('update:content', { markdown }) }, extensions: [ Autofocus.configure({ @@ -732,15 +730,15 @@ export default { * @param {object} editor The Tiptap editor */ debugContent(editor) { - const proseMirrorMarkdown = this.$syncService.serialize(editor.state.doc) - const markdownItHtml = markdownit.render(proseMirrorMarkdown) + const markdown = serializeEditorContent(editor) + const markdownItHtml = markdownit.render(markdown) logger.debug('markdown, serialized from editor state by prosemirror-markdown') - console.debug(proseMirrorMarkdown) + console.debug({ markdown }) logger.debug('HTML, serialized from markdown by markdown-it') - console.debug(markdownItHtml) + console.debug({ markdownItHtml }) logger.debug('HTML, as rendered in the browser by Tiptap') - console.debug(editor.getHTML()) + console.debug({ editorHtml: editor.getHTML() }) }, outlineToggled(visible) { diff --git a/src/services/SyncService.js b/src/services/SyncService.js index b054603f184..88bf10e9ac8 100644 --- a/src/services/SyncService.js +++ b/src/services/SyncService.js @@ -67,12 +67,13 @@ const ERROR_TYPE = { class SyncService { #sendIntervalId + #getContent constructor({ baseVersionEtag, serialize, getDocumentState, ...options }) { /** @type {import('mitt').Emitter} _bus */ this._bus = mitt() - this.serialize = serialize + this.#getContent = serialize this.getDocumentState = getDocumentState this._api = new SessionApi(options) this.connection = null @@ -240,16 +241,12 @@ class SyncService { return false } - _getContent() { - return this.serialize() - } - async save({ force = false, manualSave = true } = {}) { logger.debug('[SyncService] saving', arguments[0]) try { const response = await this.connection.save({ version: this.version, - autosaveContent: this._getContent(), + autosaveContent: this.#getContent(), documentState: this.getDocumentState(), force, manualSave, From 7af0c9e0af38ae70c0cfff5e38fbe0e54bea2216 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 18 May 2024 15:11:20 +0200 Subject: [PATCH 11/17] refactor(Markdown): simplify serialization logic The intermediate object with `serialize` and `serializer` is not needed anymore. Signed-off-by: Max --- src/extensions/Markdown.js | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/extensions/Markdown.js b/src/extensions/Markdown.js index fc43d431821..d40b5864d3a 100644 --- a/src/extensions/Markdown.js +++ b/src/extensions/Markdown.js @@ -75,7 +75,7 @@ const Markdown = Extension.create({ clipboardTextSerializer: (slice) => { const traverseNodes = (slice) => { if (slice.content.childCount > 1) { - return clipboardSerializer(this.editor.schema).serialize(slice.content) + return serializeSliceForClipboard(this.editor, slice) } else if (slice.isLeaf) { return slice.textContent } else { @@ -92,16 +92,23 @@ const Markdown = Extension.create({ }, }) -const clipboardSerializer = ({ nodes, marks }) => { - return { - serializer: new MarkdownSerializer( - extractNodesToMarkdown(nodes), - extractToPlaintext(marks), - ), - serialize(content, options) { - return this.serializer.serialize(content, { ...options, tightLists: true }) - }, - } +const serializeSliceForClipboard = ({ schema }, { content }) => { + return createTextSerializer(schema) + .serialize(content, { tightLists: true }) +} + +/* + * Create a serializer for multiple nodes: + * + * * use markdown for nodes so lists show up as lists, etc.. + * * ignore marks as these can be irritating. + * + */ +const createTextSerializer = ({ nodes, marks }) => { + return new MarkdownSerializer( + extractNodesToMarkdown(nodes), + extractToPlaintext(marks), + ) } export default Markdown From de6411d01cc0f603a010d28f3bbe3fb179b13217 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 18 May 2024 16:48:18 +0200 Subject: [PATCH 12/17] refactor(Editor.vue): use editor events not callbacks No need pass the callbacks in createEditor. Signed-off-by: Max --- src/EditorFactory.js | 4 +--- src/components/Editor.vue | 23 +++++++++++++++-------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/EditorFactory.js b/src/EditorFactory.js index 9c2a2a29338..869b6a51e74 100644 --- a/src/EditorFactory.js +++ b/src/EditorFactory.js @@ -46,7 +46,7 @@ export const loadSyntaxHighlight = async (language) => { } } -export const createEditor = ({ language, onCreate = () => {}, onUpdate = () => {}, extensions, enableRichEditing, session, relativePath, isEmbedded = false }) => { +export const createEditor = ({ language, extensions, enableRichEditing, session, relativePath, isEmbedded = false }) => { let defaultExtensions if (enableRichEditing) { defaultExtensions = [ @@ -72,8 +72,6 @@ export const createEditor = ({ language, onCreate = () => {}, onUpdate = () => { ] } return new Editor({ - onCreate, - onUpdate, editorProps: { scrollMargin: 50, scrollThreshold: 50, diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 7c4a0032844..526657b8aa7 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -414,10 +414,15 @@ export default { listenEditorEvents() { this.$editor.on('focus', this.onFocus) this.$editor.on('blur', this.onBlur) + this.$editor.on('create', this.onCreate) + this.$editor.on('update', this.onUpdate) }, + unlistenEditorEvents() { this.$editor.off('focus', this.onFocus) this.$editor.off('blur', this.onBlur) + this.$editor.off('create', this.onCreate) + this.$editor.off('update', this.onUpdate) }, listenSyncServiceEvents() { @@ -511,14 +516,6 @@ export default { language, relativePath: this.relativePath, session, - onCreate: ({ editor }) => { - this.$syncService.startSync() - }, - onUpdate: ({ editor }) => { - // this.debugContent(editor) - const markdown = serializeEditorContent(editor) - this.emit('update:content', { markdown }) - }, extensions: [ Autofocus.configure({ fileId: this.fileId, @@ -660,6 +657,16 @@ export default { this.emit('blur') }, + onCreate() { + this.$syncService.startSync() + }, + + onUpdate(editor) { + // this.debugContent(editor) + const markdown = serializeEditorContent(editor) + this.emit('update:content', { markdown }) + }, + onAddImageNode() { this.emit('add-image-node') }, From 40847babda4150c89cbe824f49908f6749472481 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 18 May 2024 20:56:57 +0200 Subject: [PATCH 13/17] refactor(Editor.vue): early return for reconnects Signed-off-by: Max --- src/components/Editor.vue | 69 +++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 36 deletions(-) diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 526657b8aa7..b3a14c1e67c 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -494,9 +494,6 @@ export default { this.currentSession = session this.document = document this.readOnly = document.readOnly - if (this.$editor) { - this.$editor.setEditable(!this.readOnly) - } this.lock = this.$syncService.lock localStorage.setItem('nick', this.currentSession.guestName) this.$attachmentResolver = new AttachmentResolver({ @@ -507,43 +504,43 @@ export default { }) this.hasConnectionIssue = false + if (this.$editor) { + // $editor already existed. So this is a reconnect. + this.$editor.setEditable(!this.readOnly) + this.$syncService.startSync() + return + } const language = extensionHighlight[this.fileExtension] || this.fileExtension; (this.isRichEditor ? Promise.resolve() : loadSyntaxHighlight(language)) .then(() => { - if (!this.$editor) { - this.$editor = createEditor({ - language, - relativePath: this.relativePath, - session, - extensions: [ - Autofocus.configure({ - fileId: this.fileId, - }), - Collaboration.configure({ - document: this.$ydoc, - }), - CollaborationCursor.configure({ - provider: this.$providers[0], - user: { - name: session?.userId - ? session.displayName - : (session?.guestName || t('text', 'Guest')), - color: session?.color, - clientId: this.$ydoc.clientID, - }, - }), - ], - enableRichEditing: this.isRichEditor, - isEmbedded: this.isEmbedded, - }) - this.hasEditor = true - this.listenEditorEvents() - } else { - // $editor already existed. So this is a reconnect. - this.$syncService.startSync() - } - + this.$editor = createEditor({ + language, + relativePath: this.relativePath, + session, + extensions: [ + Autofocus.configure({ + fileId: this.fileId, + }), + Collaboration.configure({ + document: this.$ydoc, + }), + CollaborationCursor.configure({ + provider: this.$providers[0], + user: { + name: session?.userId + ? session.displayName + : (session?.guestName || t('text', 'Guest')), + color: session?.color, + clientId: this.$ydoc.clientID, + }, + }), + ], + enableRichEditing: this.isRichEditor, + isEmbedded: this.isEmbedded, + }) + this.hasEditor = true + this.listenEditorEvents() }) }, From e74c520d0ef1982c3085b0b4599ff8745fc3b639 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 18 May 2024 21:12:02 +0200 Subject: [PATCH 14/17] fix(lint): also lint .ts files Signed-off-by: Max --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 1a019341421..8a245a41e75 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,8 @@ "build": "NODE_OPTIONS='--max-old-space-size=4096' vite --mode production build", "dev": "NODE_OPTIONS='--max-old-space-size=4096' vite --mode development build", "watch": "NODE_OPTIONS='--max-old-space-size=4096' vite --mode development build --watch", - "lint": "tsc && eslint --ext .js,.vue src cypress", - "lint:fix": "tsc && eslint --ext .js,.vue src cypress --fix", + "lint": "tsc && eslint --ext .ts,.js,.vue src cypress", + "lint:fix": "tsc && eslint --ext .ts,.js,.vue src cypress --fix", "stylelint": "stylelint src/**/*.vue src/**/*.scss src/**/*.css css/*.scss", "stylelint:fix": "stylelint src/**/*.vue src/**/*.scss src/**/*.css css/*.scss --fix", "test": "NODE_ENV=test jest", From d489836a7e9246318b1e9ffac8b8ae2583d681b8 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 18 May 2024 21:12:48 +0200 Subject: [PATCH 15/17] refactor(EditorFactory): split createRichEditor and createPlainEditor Signed-off-by: Max --- src/EditorFactory.js | 59 ++++++++++++++++++----------------- src/components/Editor.vue | 59 ++++++++++++++++++++--------------- src/mixins/setContent.js | 8 ++--- src/tests/builders.js | 9 ++---- src/tests/helpers.js | 14 +++------ src/tests/markdown.spec.js | 6 ++-- src/tests/nodes/Table.spec.js | 6 ++-- src/tests/plaintext.spec.js | 6 ++-- src/tests/tiptap.spec.js | 6 ++-- 9 files changed, 82 insertions(+), 91 deletions(-) diff --git a/src/EditorFactory.js b/src/EditorFactory.js index 869b6a51e74..c47b7347634 100644 --- a/src/EditorFactory.js +++ b/src/EditorFactory.js @@ -46,36 +46,37 @@ export const loadSyntaxHighlight = async (language) => { } } -export const createEditor = ({ language, extensions, enableRichEditing, session, relativePath, isEmbedded = false }) => { - let defaultExtensions - if (enableRichEditing) { - defaultExtensions = [ - FocusTrap, - RichText.configure({ - relativePath, - isEmbedded, - component: this, - extensions: [ - Mention.configure({ - suggestion: MentionSuggestion({ - session, - }), +export const createRichEditor = ({ extensions = [], session, relativePath, isEmbedded = false } = {}) => { + return _createEditor([ + FocusTrap, + RichText.configure({ + relativePath, + isEmbedded, + component: this, + extensions: [ + Mention.configure({ + suggestion: MentionSuggestion({ + session, }), - ], - }), - ] - } else { - defaultExtensions = [ - PlainText, - PlainTextLowlight - .configure({ lowlight, defaultLanguage: language }), - ] - } + }), + ], + }), + ...extensions, + ]) +} + +export const createPlainEditor = ({ language, extensions = [] } = {}) => { + return _createEditor([ + PlainText, + PlainTextLowlight + .configure({ lowlight, defaultLanguage: language }), + ...extensions, + ]) +} + +const _createEditor = extensions => { return new Editor({ - editorProps: { - scrollMargin: 50, - scrollThreshold: 50, - }, - extensions: defaultExtensions.concat(extensions || []), + editorProps: { scrollMargin: 50, scrollThreshold: 50 }, + extensions, }) } diff --git a/src/components/Editor.vue b/src/components/Editor.vue index b3a14c1e67c..ff5082d40e4 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -114,7 +114,11 @@ import { SyncService, ERROR_TYPE, IDLE_TIMEOUT } from './../services/SyncService import createSyncServiceProvider from './../services/SyncServiceProvider.js' import AttachmentResolver from './../services/AttachmentResolver.js' import { extensionHighlight } from '../helpers/mappings.js' -import { createEditor, loadSyntaxHighlight } from './../EditorFactory.js' +import { + createRichEditor, + createPlainEditor, + loadSyntaxHighlight, +} from './../EditorFactory.js' import { serializeEditorContent } from './../extensions/Serializer.js' import markdownit from './../markdownit/index.js' @@ -510,35 +514,38 @@ export default { this.$syncService.startSync() return } + + const extensions = [ + Autofocus.configure({ + fileId: this.fileId, + }), + Collaboration.configure({ + document: this.$ydoc, + }), + CollaborationCursor.configure({ + provider: this.$providers[0], + user: { + name: session?.userId + ? session.displayName + : (session?.guestName || t('text', 'Guest')), + color: session?.color, + clientId: this.$ydoc.clientID, + }, + }), + ] + const language = extensionHighlight[this.fileExtension] || this.fileExtension; (this.isRichEditor ? Promise.resolve() : loadSyntaxHighlight(language)) .then(() => { - this.$editor = createEditor({ - language, - relativePath: this.relativePath, - session, - extensions: [ - Autofocus.configure({ - fileId: this.fileId, - }), - Collaboration.configure({ - document: this.$ydoc, - }), - CollaborationCursor.configure({ - provider: this.$providers[0], - user: { - name: session?.userId - ? session.displayName - : (session?.guestName || t('text', 'Guest')), - color: session?.color, - clientId: this.$ydoc.clientID, - }, - }), - ], - enableRichEditing: this.isRichEditor, - isEmbedded: this.isEmbedded, - }) + this.$editor = this.isRichEditor + ? createRichEditor({ + relativePath: this.relativePath, + session, + extensions, + isEmbedded: this.isEmbedded, + }) + : createPlainEditor({ language, extensions }) this.hasEditor = true this.listenEditorEvents() }) diff --git a/src/mixins/setContent.js b/src/mixins/setContent.js index 0126fde5668..cbc60cbf24e 100644 --- a/src/mixins/setContent.js +++ b/src/mixins/setContent.js @@ -26,7 +26,7 @@ import { Doc, encodeStateAsUpdate, XmlFragment, applyUpdate } from 'yjs' import { generateJSON } from '@tiptap/core' import { prosemirrorToYXmlFragment } from 'y-prosemirror' import { Node } from '@tiptap/pm/model' -import { createEditor } from '../EditorFactory.js' +import { createRichEditor, createPlainEditor } from '../EditorFactory.js' export default { methods: { @@ -48,9 +48,9 @@ export default { ? markdownit.render(content) + '

' : `

${escapeHtml(content)}
` - const editor = createEditor({ - enableRichEditing: isRichEditor, - }) + const editor = isRichEditor + ? createRichEditor() + : createPlainEditor() const json = generateJSON(html, editor.extensionManager.extensions) const doc = Node.fromJSON(editor.schema, json) diff --git a/src/tests/builders.js b/src/tests/builders.js index a3f7de05526..297f0ae2d26 100644 --- a/src/tests/builders.js +++ b/src/tests/builders.js @@ -1,14 +1,11 @@ import { expect } from '@jest/globals'; import { Mark, Node } from '@tiptap/pm/model' import { builders } from 'prosemirror-test-builder' -import { createEditor } from '../EditorFactory' +import { createRichEditor } from '../EditorFactory' export function getBuilders() { - const editor = createEditor({ - content: '', - enableRichEditing: true - }) + const editor = createRichEditor() return builders(editor.schema, { tr: { nodeType: 'tableRow' }, td: { nodeType: 'tableCell' }, @@ -79,7 +76,7 @@ function createDocumentString(node) { * @param {Node} subject The editor document * @param {Node} expected The expected document * @example - * const editor = createEditor() + * const editor = createRichEditor() * expectDocument(editor.state.doc, table( * tr( * td('foo') diff --git a/src/tests/helpers.js b/src/tests/helpers.js index 1f6f563b603..ae6bc6b5621 100644 --- a/src/tests/helpers.js +++ b/src/tests/helpers.js @@ -5,7 +5,7 @@ import Document from '@tiptap/extension-document' import Paragraph from '../nodes/Paragraph' import Text from '@tiptap/extension-text' -import { createEditor } from '../EditorFactory' +import { createRichEditor } from '../EditorFactory' import markdownit from '../markdownit' export function createCustomEditor({ content, extensions }) { @@ -27,9 +27,7 @@ export function createCustomEditor({ content, extensions }) { * @returns {string} */ export function markdownThroughEditor(markdown) { - const tiptap = createEditor({ - enableRichEditing: true - }) + const tiptap = createRichEditor() tiptap.commands.setContent(markdownit.render(markdown)) return serializeEditorContent(tiptap) } @@ -41,9 +39,7 @@ export function markdownThroughEditor(markdown) { * @returns {string} */ export function markdownThroughEditorHtml(html) { - const tiptap = createEditor({ - enableRichEditing: true - }) + const tiptap = createRichEditor() tiptap.commands.setContent(html) return serializeEditorContent(tiptap) } @@ -55,9 +51,7 @@ export function markdownThroughEditorHtml(html) { * @returns {string} */ export function markdownFromPaste(html) { - const tiptap = createEditor({ - enableRichEditing: true - }) + const tiptap = createRichEditor() tiptap.commands.insertContent(html) return serializeEditorContent(tiptap) } diff --git a/src/tests/markdown.spec.js b/src/tests/markdown.spec.js index 18b1a571a00..19f4db49fc1 100644 --- a/src/tests/markdown.spec.js +++ b/src/tests/markdown.spec.js @@ -6,7 +6,7 @@ import { markdownFromPaste } from './helpers.js' import { serializeEditorContent } from "../extensions/Serializer"; -import { createEditor } from "../EditorFactory"; +import { createRichEditor } from "../EditorFactory"; /* * This file is for various markdown tests, mainly testing if input and output stays the same. @@ -190,9 +190,7 @@ describe('Markdown serializer from html', () => { describe('Trailing nodes', () => { test('No extra transaction is added after loading', () => { const source = "# My heading\n\n* test\n* test2" - const tiptap = createEditor({ - enableRichEditing: true, - }) + const tiptap = createRichEditor() tiptap.commands.setContent(markdownit.render(source)) const jsonBefore = tiptap.getJSON() diff --git a/src/tests/nodes/Table.spec.js b/src/tests/nodes/Table.spec.js index b42297c511b..779f95a2a38 100644 --- a/src/tests/nodes/Table.spec.js +++ b/src/tests/nodes/Table.spec.js @@ -1,4 +1,4 @@ -import { createEditor } from '../../EditorFactory' +import { createRichEditor } from '../../EditorFactory' import { serializeEditorContent } from '../../extensions/Serializer' import { builders } from 'prosemirror-test-builder' @@ -68,9 +68,7 @@ describe('Table', () => { }) function editorWithContent(content) { - const editor = createEditor({ - enableRichEditing: true, - }) + const editor = createRichEditor() editor.commands.setContent(content) return editor } diff --git a/src/tests/plaintext.spec.js b/src/tests/plaintext.spec.js index f38982c03b2..e78d380c21d 100644 --- a/src/tests/plaintext.spec.js +++ b/src/tests/plaintext.spec.js @@ -1,4 +1,4 @@ -import { createEditor } from './../EditorFactory'; +import { createPlainEditor } from './../EditorFactory'; import { serializeEditorContent } from './../extensions/Serializer.js' import spec from "./fixtures/spec" import xssFuzzVectors from './fixtures/xssFuzzVectors'; @@ -14,9 +14,7 @@ const escapeHTML = (s) => { const plaintextThroughEditor = (markdown) => { const content = '
' + escapeHTML(markdown) + '
' - const tiptap = createEditor({ - enableRichEditing: false - }) + const tiptap = createPlainEditor() tiptap.commands.setContent(content) return serializeEditorContent(tiptap) || 'failed' } diff --git a/src/tests/tiptap.spec.js b/src/tests/tiptap.spec.js index a0a615ef982..b4c6729599b 100644 --- a/src/tests/tiptap.spec.js +++ b/src/tests/tiptap.spec.js @@ -1,10 +1,8 @@ -import { createEditor } from '../EditorFactory' +import { createRichEditor } from '../EditorFactory' import markdownit from '../markdownit' const renderedHTML = ( markdown ) => { - const editor = createEditor({ - enableRichEditing: true - }) + const editor = createRichEditor() editor.commands.setContent(markdownit.render(markdown)) // Remove TrailingNode return editor.getHTML().replace(/

<\/p>$/, '') From 0cd8d8b48f72508c448ac970cf1231e7a2ff5645 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 18 May 2024 21:21:53 +0200 Subject: [PATCH 16/17] cleanup(RichText): unused component option Seems to be unused at the moment. Also did not find any use for it in the commit that introduced it. Signed-off-by: Max --- src/EditorFactory.js | 1 - src/extensions/RichText.js | 1 - 2 files changed, 2 deletions(-) diff --git a/src/EditorFactory.js b/src/EditorFactory.js index c47b7347634..744f1d1d608 100644 --- a/src/EditorFactory.js +++ b/src/EditorFactory.js @@ -52,7 +52,6 @@ export const createRichEditor = ({ extensions = [], session, relativePath, isEmb RichText.configure({ relativePath, isEmbedded, - component: this, extensions: [ Mention.configure({ suggestion: MentionSuggestion({ diff --git a/src/extensions/RichText.js b/src/extensions/RichText.js index 651865de9fd..01717bface9 100644 --- a/src/extensions/RichText.js +++ b/src/extensions/RichText.js @@ -72,7 +72,6 @@ export default Extension.create({ return { editing: true, extensions: [], - component: null, relativePath: null, isEmbedded: false, } From fae0ddf6b9f602c869f1fb462d3736d567c8cd6a Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 21 May 2024 09:56:42 +0200 Subject: [PATCH 17/17] refactor(Editor): split async `createEditor` from `onOpened` Signed-off-by: Max --- src/components/Editor.vue | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/src/components/Editor.vue b/src/components/Editor.vue index ff5082d40e4..995209aaf16 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -514,6 +514,16 @@ export default { this.$syncService.startSync() return } + this.createEditor() + .then(editor => { + this.$editor = editor + this.hasEditor = true + this.listenEditorEvents() + }) + }, + + async createEditor() { + const session = this.currentSession const extensions = [ Autofocus.configure({ @@ -534,21 +544,19 @@ export default { }), ] - const language = extensionHighlight[this.fileExtension] || this.fileExtension; - - (this.isRichEditor ? Promise.resolve() : loadSyntaxHighlight(language)) - .then(() => { - this.$editor = this.isRichEditor - ? createRichEditor({ - relativePath: this.relativePath, - session, - extensions, - isEmbedded: this.isEmbedded, - }) - : createPlainEditor({ language, extensions }) - this.hasEditor = true - this.listenEditorEvents() + const language = extensionHighlight[this.fileExtension] || this.fileExtension + + if (this.isRichEditor) { + return createRichEditor({ + relativePath: this.relativePath, + session, + extensions, + isEmbedded: this.isEmbedded, }) + } else { + await loadSyntaxHighlight(language) + return createPlainEditor({ language, extensions }) + } }, onLoaded({ documentSource, documentState }) {