From 95764c68e3d9a33a37d090fa16a3c85e741f0d49 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 1 Jul 2025 15:45:46 +0200 Subject: [PATCH 01/27] chore(split): SaveService from SyncService Signed-off-by: Max --- src/components/CollisionResolveDialog.vue | 8 +- src/components/Editor.provider.ts | 7 ++ src/components/Editor.vue | 32 ++++--- src/components/Editor/Status.vue | 6 +- src/services/SaveService.js | 91 +++++++++++++++++++ src/services/SyncService.js | 106 +++++----------------- 6 files changed, 149 insertions(+), 101 deletions(-) create mode 100644 src/services/SaveService.js diff --git a/src/components/CollisionResolveDialog.vue b/src/components/CollisionResolveDialog.vue index 7bdf0439b98..56d91ffc2e1 100644 --- a/src/components/CollisionResolveDialog.vue +++ b/src/components/CollisionResolveDialog.vue @@ -29,7 +29,7 @@ diff --git a/src/components/CollisionResolveDialog.vue b/src/components/CollisionResolveDialog.vue index bdb9854bc2f..729e4cae18b 100644 --- a/src/components/CollisionResolveDialog.vue +++ b/src/components/CollisionResolveDialog.vue @@ -51,7 +51,6 @@ export default { const { saveService } = useSaveService() const { setContent, setEditable } = useEditorMethods(editor) return { - editor, setContent, setEditable, saveService, diff --git a/src/components/Editor.vue b/src/components/Editor.vue index ce5085a6cce..b477af825c2 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -235,33 +235,17 @@ export default { const wrappedConnection = provideConnection() const hasConnectionIssue = ref(false) const { delayed: requireReconnect } = useDelayedFlag(hasConnectionIssue) - const { editor } = provideEditor() - const { setEditable } = useEditorMethods(editor) const { isPublic, isRichEditor, isRichWorkspace } = provideEditorFlags(props) const { language, lowlightLoaded } = useSyntaxHighlighting( isRichEditor, props, ) - const { syncService, connectSyncService, baseVersionEtag } = - provideSyncService(props) - - const serialize = isRichEditor.value - ? () => - createMarkdownSerializer(editor.value?.schema).serialize( - editor.value?.state.doc, - ) - : () => serializePlainText(editor.value?.state.doc) - - const { saveService } = provideSaveService(syncService, serialize, ydoc) - - const syncProvider = shallowRef(null) - const extensions = [ Autofocus.configure({ fileId: props.fileId }), Collaboration.configure({ document: ydoc }), CollaborationCursor.configure({ provider: { awareness } }), ] - editor.value = isRichEditor + const editor = isRichEditor ? createRichEditor({ ...wrappedConnection, relativePath: props.relativePath, @@ -269,6 +253,22 @@ export default { isEmbedded: props.isEmbedded, }) : createPlainEditor({ language, extensions }) + provideEditor(editor) + + const { setEditable } = useEditorMethods(editor) + const { syncService, connectSyncService, baseVersionEtag } = + provideSyncService(props) + + const serialize = isRichEditor.value + ? () => + createMarkdownSerializer(editor.schema).serialize( + editor.state.doc, + ) + : () => serializePlainText(editor.state.doc) + + const { saveService } = provideSaveService(syncService, serialize, ydoc) + + const syncProvider = shallowRef(null) return { awareness, @@ -454,17 +454,17 @@ 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) + 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) + 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() { @@ -682,9 +682,6 @@ export default { }, onStateChange(state) { - if (!this.editor) { - return - } if (state.initialLoading && !this.contentLoaded) { this.contentLoaded = true if (this.autofocus && !this.readOnly) { @@ -865,7 +862,7 @@ export default { this.translateModal = false }, applyCommand(fn) { - this.editor?.commands?.command(fn) + this.editor.commands?.command(fn) }, translateInsert(content) { this.applyCommand(({ tr, commands }) => { @@ -888,8 +885,8 @@ export default { handleEditorWidthChange(newWidth) { this.updateEditorWidth(newWidth) this.$nextTick(() => { - this.editor?.view.updateState(this.editor?.view.state) - this.editor?.commands.focus() + this.editor.view.updateState(this.editor.view.state) + this.editor.commands.focus() }) }, updateEditorWidth(newWidth) { diff --git a/src/components/Editor/MarkdownContentEditor.vue b/src/components/Editor/MarkdownContentEditor.vue index 0279c571aa9..99af8598a77 100644 --- a/src/components/Editor/MarkdownContentEditor.vue +++ b/src/components/Editor/MarkdownContentEditor.vue @@ -37,7 +37,7 @@ import { RichText, FocusTrap } from '../../extensions/index.js' import ReadonlyBar from '../Menu/ReadonlyBar.vue' import ContentContainer from './ContentContainer.vue' import { useEditorMethods } from '../../composables/useEditorMethods.ts' -import { provide, ref } from 'vue' +import { provide, ref, watch } from 'vue' export default { name: 'MarkdownContentEditor', @@ -86,32 +86,49 @@ export default { }, emits: ['update:content'], - setup() { - const { editor } = provideEditor() - const { setEditable } = useEditorMethods(editor) + setup(props) { + const extensions = [ + RichText.configure({ + extensions: [ History ], + }), + FocusTrap, + ] + const editor = new Editor({ + content: markdownit.render(props.content), + extensions, + }) + + const { setEditable, setContent } = useEditorMethods(editor) + watch(() => props.content, (content) => { + setContent(content) + }) + + setEditable(!props.readOnly) + watch(() => props.readOnly, (readOnly) => { + setEditable(!readOnly) + }) + + provideEditor(editor) provide(editorFlagsKey, { isPublic: ref(false), isRichEditor: ref(true), isRichWorkspace: ref(false), }) - return { editor, setEditable } - }, - - computed: { - htmlContent() { - return this.renderHtml(this.content) - }, - }, - - watch: { - content() { - this.updateContent() - }, + return { editor } }, created() { - this.editor = this.createEditor() - this.setEditable(!this.readOnly) + this.editor.on('create', () => { + this.$emit('ready') + this.$parent.$emit('ready') + }) + this.editor.on('update', ({ editor }) => { + const markdown = (createMarkdownSerializer(editor.schema)).serialize(editor.state.doc) + this.emit('update:content', { + json: editor.state.doc, + markdown, + }) + }) if (this.fileId) { this.$attachmentResolver = new AttachmentResolver({ currentDirectory: this.relativePath?.match(/.*\//), @@ -122,50 +139,11 @@ export default { } }, - updated() { - this.setEditable(!this.readOnly) - }, - beforeDestroy() { - this.editor?.destroy() + this.editor.destroy() }, methods: { - renderHtml(content) { - return markdownit.render(content) - }, - extensions() { - return [ - RichText.configure({ - component: this, - extensions: [ - History, - ], - }), - FocusTrap, - ] - }, - createEditor() { - return new Editor({ - content: this.htmlContent, - extensions: this.extensions(), - onUpdate: ({ editor }) => { - const markdown = (createMarkdownSerializer(this.editor?.schema)).serialize(editor.state.doc) - this.emit('update:content', { - json: editor.state.doc, - markdown, - }) - }, - onCreate: ({ editor }) => { - this.$emit('ready') - this.$parent.$emit('ready') - }, - }) - }, - - updateContent() { - this.editor?.commands.setContent(this.htmlContent, true) - }, outlineToggled(visible) { this.emit('outline-toggled', visible) diff --git a/src/components/Editor/MediaHandler.vue b/src/components/Editor/MediaHandler.vue index 7c6eece5017..cf2e3cb1fb7 100644 --- a/src/components/Editor/MediaHandler.vue +++ b/src/components/Editor/MediaHandler.vue @@ -194,7 +194,7 @@ export default { insertAttachmentPreview(fileId) { const url = new URL(generateUrl(`/f/${fileId}`), window.origin) const href = url.href.replaceAll(' ', '%20') - this.editor?.chain() + this.editor.chain() .focus() .insertPreview(href) .run() @@ -210,9 +210,6 @@ export default { // as it does not need to be unique and matching the real file name const alt = name.replaceAll(/[[\]]/g, '') - if (!this.editor) { - return - } const chain = position ? this.editor.chain().focus(position) : this.editor.chain() diff --git a/src/components/Editor/TableOfContents.vue b/src/components/Editor/TableOfContents.vue index fd402771b98..3c4bf2d3a20 100644 --- a/src/components/Editor/TableOfContents.vue +++ b/src/components/Editor/TableOfContents.vue @@ -37,10 +37,8 @@ export default { headings: [], }), mounted() { - if (this.editor) { - this.editor.on('update', this.updateHeadings) - this.updateHeadings() - } + this.editor.on('update', this.updateHeadings) + this.updateHeadings() setTimeout(() => { this.initialRender = false }, 1000) @@ -58,7 +56,7 @@ export default { }, updateHeadings() { this.headings = headingAnchorPluginKey - .getState(this.editor?.state)?.headings ?? [] + .getState(this.editor.state)?.headings ?? [] }, }, } diff --git a/src/components/Menu/BaseActionEntry.js b/src/components/Menu/BaseActionEntry.js index 3ac88750823..3b301ae6a95 100644 --- a/src/components/Menu/BaseActionEntry.js +++ b/src/components/Menu/BaseActionEntry.js @@ -77,16 +77,16 @@ const BaseActionEntry = { }, mounted() { this.$_updateState = debounce(this.updateState.bind(this), 50) - this.editor?.on('update', this.$_updateState) - this.editor?.on('selectionUpdate', this.$_updateState) + this.editor.on('update', this.$_updateState) + this.editor.on('selectionUpdate', this.$_updateState) // Initially emit the disabled event to set the state in parent this.$emit('disabled', this.state.disabled) // Initially set the tabindex this.setTabIndexOnButton() }, beforeDestroy() { - this.editor?.off('update', this.$_updateState) - this.editor?.off('selectionUpdate', this.$_updateState) + this.editor.off('update', this.$_updateState) + this.editor.off('selectionUpdate', this.$_updateState) }, methods: { updateState() { diff --git a/src/components/Menu/CharacterCount.vue b/src/components/Menu/CharacterCount.vue index b67db8ae65d..0677ec38b15 100644 --- a/src/components/Menu/CharacterCount.vue +++ b/src/components/Menu/CharacterCount.vue @@ -31,10 +31,7 @@ export default defineComponent({ const { editor } = useEditor() const countString = ref('') const refresh = () => { - if (!editor.value) { - return - } - const { storage, state } = editor.value + const { storage, state } = editor // characterCount is not reactive so we need this workaround // We also need to provide the doc as storage is a singleton in tiptap v2. // See ueberdosis/tiptap#6060 @@ -45,7 +42,7 @@ export default defineComponent({ countString.value = [words, chars].join(', ') console.debug({ wordCount, charCount, countString: countString.value }) } - return { countString, editor, refresh } + return { countString, refresh } }, watch: { visible: 'refresh', diff --git a/src/components/Menu/MenuBar.vue b/src/components/Menu/MenuBar.vue index 937cf359c00..f562759f3c2 100644 --- a/src/components/Menu/MenuBar.vue +++ b/src/components/Menu/MenuBar.vue @@ -207,9 +207,6 @@ export default { this.displayHelp = false }, showTranslate() { - if(!this.editor) { - return - } const { commands, view: { state }} = this.editor const { from, to } = state.selection let selectedText = state.doc.textBetween(from, to, ' ') diff --git a/src/components/SuggestionsBar.vue b/src/components/SuggestionsBar.vue index cf26615aa41..5a64b397c81 100644 --- a/src/components/SuggestionsBar.vue +++ b/src/components/SuggestionsBar.vue @@ -116,12 +116,12 @@ export default { }, mounted() { - this.editor?.on('update', this.onUpdate) + this.editor.on('update', this.onUpdate) this.onUpdate({ editor: this.editor }) }, beforeDestroy() { - this.editor?.off('update', this.onUpdate) + this.editor.off('update', this.onUpdate) }, methods: { @@ -132,8 +132,8 @@ export default { linkPicker() { getLinkWithPicker(null, true) .then((link) => { - const chain = this.editor?.chain() - if (this.editor?.view.state?.selection.empty) { + const chain = this.editor.chain() + if (this.editor.view.state?.selection.empty) { chain.focus().insertPreview(link).run() } else { chain.setLink({ href: link }).focus().run() @@ -149,7 +149,7 @@ export default { * Triggered by the "Insert table" button */ insertTable() { - this.editor?.chain().focus().insertTable()?.run() + this.editor.chain().focus().insertTable()?.run() }, /** @@ -192,7 +192,7 @@ export default { * @param {string} text Text part of the link */ setLink(url, text) { - this.editor?.chain().insertOrSetLink(text, { href: url }).focus().run() + this.editor.chain().insertOrSetLink(text, { href: url }).focus().run() }, onUpdate({ editor }) { diff --git a/src/composables/useEditor.ts b/src/composables/useEditor.ts index 26a03cb2971..05f3bd48b97 100644 --- a/src/composables/useEditor.ts +++ b/src/composables/useEditor.ts @@ -3,18 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Editor } from '@tiptap/core' -import { type InjectionKey, type ShallowRef, shallowRef, provide, inject } from 'vue' +import { Editor } from '@tiptap/core' +import { type InjectionKey, provide, inject } from 'vue' -export const editorKey = Symbol('tiptap:editor') as InjectionKey< - ShallowRef -> -export const provideEditor = () => { - const editor: ShallowRef = shallowRef(undefined) +export const editorKey = Symbol('tiptap:editor') as InjectionKey +export const provideEditor = (editor: Editor) => { provide(editorKey, editor) - return { editor } } export const useEditor = () => { - const editor = inject(editorKey, shallowRef(undefined)) - return { editor } + const editor = inject(editorKey) + if (!editor) { + throw new Error('Failed to inject Editor') + } + return { editor: editor as Editor } } diff --git a/src/composables/useEditorMethods.ts b/src/composables/useEditorMethods.ts index ef954cbfa20..12425b475f9 100644 --- a/src/composables/useEditorMethods.ts +++ b/src/composables/useEditorMethods.ts @@ -5,20 +5,17 @@ import type { Editor } from '@tiptap/core' import escapeHtml from 'escape-html' -import { computed, type ShallowRef } from 'vue' import markdownit from '../markdownit/index.js' import Markdown from '../extensions/Markdown.js' -export const useEditorMethods = (editor: ShallowRef) => { +export const useEditorMethods = (editor: Editor) => { const setEditable = (val: boolean) => { - if (editor.value && editor.value.isEditable !== val) { - editor.value.setEditable(val) + if (editor && editor.isEditable !== val) { + editor.setEditable(val) } } - const isRichEditor = computed(() => - editor.value?.extensionManager.extensions.includes(Markdown), - ) + const isRichEditor = editor.extensionManager.extensions.includes(Markdown) const setContent: ( content: string, @@ -27,8 +24,8 @@ export const useEditorMethods = (editor: ShallowRef) => { const html = isRichEditor ? markdownit.render(content) + '

' : `

${escapeHtml(content)}
` - editor.value - ?.chain() + editor + .chain() .setContent(html, addToHistory) .command(({ tr }) => { tr.setMeta('addToHistory', addToHistory) diff --git a/src/extensions/RichText.js b/src/extensions/RichText.js index eadaeb0f0ca..5328a647f51 100644 --- a/src/extensions/RichText.js +++ b/src/extensions/RichText.js @@ -58,7 +58,6 @@ export default Extension.create({ return { editing: true, extensions: [], - component: null, relativePath: null, isEmbedded: false, } diff --git a/src/tests/components/Editor/TableOfContents.spec.js b/src/tests/components/Editor/TableOfContents.spec.js index 6baccea389f..948a405f902 100644 --- a/src/tests/components/Editor/TableOfContents.spec.js +++ b/src/tests/components/Editor/TableOfContents.spec.js @@ -24,12 +24,6 @@ const mountWithEditor = (editor) => { }) } -test('mounts without editor', () => { - const wrapper = mount(TableOfContents, {}) - expect(wrapper.text()).toEqual('') - expect(wrapper.vm.headings).toEqual([]) -}) - test('renders nothing for editor without headings', () => { const editor = createEditor('no heading here') const wrapper = mountWithEditor(editor) From e8d184a20d0ecc6df0a480c82748577d0e02b987 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 3 Jul 2025 15:00:04 +0200 Subject: [PATCH 09/27] refactor(cleanup): unwrap connection The editor extensions need access to the ref. Now that the editor is created in the setup function we do not need to wrap the connection anymore. This only prevented it from being autounrefed in this. Signed-off-by: Max --- src/components/Editor.vue | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/components/Editor.vue b/src/components/Editor.vue index b477af825c2..b6b09a1beb5 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -232,7 +232,7 @@ export default { const ydoc = new Doc() const awareness = new Awareness(ydoc) // Wrap the connection in an object so we can hand it to the Mention extension as a ref. - const wrappedConnection = provideConnection() + const { connection } = provideConnection() const hasConnectionIssue = ref(false) const { delayed: requireReconnect } = useDelayedFlag(hasConnectionIssue) const { isPublic, isRichEditor, isRichWorkspace } = provideEditorFlags(props) @@ -247,7 +247,7 @@ export default { ] const editor = isRichEditor ? createRichEditor({ - ...wrappedConnection, + connection, relativePath: props.relativePath, extensions, isEmbedded: props.isEmbedded, @@ -289,7 +289,7 @@ export default { syncProvider, syncService, width, - wrappedConnection, + connection, ydoc, } }, @@ -494,7 +494,7 @@ export default { reconnect() { this.contentLoaded = false this.hasConnectionIssue = false - this.wrappedConnection.connection.value = undefined + this.connection = undefined this.disconnect().then(() => { this.initSession() }) @@ -600,9 +600,7 @@ export default { color: session?.color, clientId: this.ydoc.clientID, } - this.wrappedConnection.connection.value = { - ...this.currentSession, - } + this.connection = { ...this.currentSession } this.editor.commands.updateUser(user) }, From c856f2f073502941415ca1073fd1531ea725db39 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 3 Jul 2025 15:49:11 +0200 Subject: [PATCH 10/27] chore(minor): clean up redundant injects Signed-off-by: Max --- src/components/BaseReader.vue | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/BaseReader.vue b/src/components/BaseReader.vue index f9b2d0f9780..740fd74e771 100644 --- a/src/components/BaseReader.vue +++ b/src/components/BaseReader.vue @@ -42,9 +42,6 @@ export default { mixins: [useOutlineStateMixin, useOutlineActions], - // extensions is a factory building a list of extensions for the editor - inject: ['renderHtml', 'extensions'], - props: { content: { type: String, @@ -53,6 +50,7 @@ export default { }, setup(props) { + // extensions is a factory building a list of extensions for the editor const extensions = inject('extensions') const renderHtml = inject('renderHtml') const editor = new Editor({ From 00c18c49c960a79e27e53a0d520c9f3bba0b20e5 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 3 Jul 2025 15:51:16 +0200 Subject: [PATCH 11/27] chore(refactor): simplify types for props Props are reactive. But so far we make no use of that for most of the props in Editor.vue. Use primitives for derived values for now. Signed-off-by: Max --- src/components/BaseReader.vue | 11 +++--- src/components/Editor.vue | 8 ++--- .../Editor/MarkdownContentEditor.vue | 8 ++--- src/composables/useEditorFlags.ts | 36 +++++++------------ src/composables/useSyncService.ts | 13 ++----- src/composables/useSyntaxHighlighting.ts | 9 +++-- 6 files changed, 35 insertions(+), 50 deletions(-) diff --git a/src/components/BaseReader.vue b/src/components/BaseReader.vue index 740fd74e771..63b2bfb1ab5 100644 --- a/src/components/BaseReader.vue +++ b/src/components/BaseReader.vue @@ -58,10 +58,13 @@ export default { extensions: extensions(), }) provideEditor(editor) - watch(() => props.content, (content) => { - console.warn({content}) - editor.commands.setContent(renderHtml(content), true) - }) + watch( + () => props.content, + (content) => { + console.warn({ content }) + editor.commands.setContent(renderHtml(content), true) + }, + ) const { setEditable } = useEditorMethods(editor) setEditable(false) return { editor } diff --git a/src/components/Editor.vue b/src/components/Editor.vue index b6b09a1beb5..0c0d2cdde49 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -81,7 +81,7 @@