diff --git a/cypress/e2e/RawEditing.spec.js b/cypress/e2e/RawEditing.spec.js new file mode 100644 index 00000000000..1839d936811 --- /dev/null +++ b/cypress/e2e/RawEditing.spec.js @@ -0,0 +1,99 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { randUser } from '../utils/index.js' + +const user = randUser() + +describe('Raw Markdown editing', function() { + before(function() { + cy.createUser(user) + }) + + beforeEach(function() { + cy.login(user) + cy.uploadTestFile() + cy.visit('/apps/files') + cy.openTestFile() + // Seed some rich content to round-trip. + cy.getContent().type('# Hello{enter}World') + cy.getContent().find('h1').should('contain.text', 'Hello') + }) + + const openRawEditor = function() { + cy.getActionEntry('remain').click() + cy.contains('Edit Markdown source').click() + return cy.get('[data-text-el="raw-markdown-editor"]').should('exist') + } + + const rawContent = function() { + return cy.get('[data-text-el="raw-markdown-editor"] .ProseMirror') + } + + it('shows the Markdown source when toggled on', function() { + openRawEditor() + rawContent().should('contain.text', '# Hello') + // The rich formatting toolbar is hidden while editing raw. + cy.get('div[data-text-el="menubar"] .text-menubar__entries').should('not.exist') + }) + + it('applies raw edits back into the rich editor', function() { + openRawEditor() + rawContent().type('{selectall}{del}## Changed heading{enter}{enter}body text') + cy.get('[data-cy="exitRawEditing"]').click() + cy.get('[data-text-el="raw-markdown-editor"]').should('not.exist') + cy.getContent().find('h2').should('contain.text', 'Changed heading') + cy.getContent().should('contain.text', 'body text') + cy.getContent().find('h1').should('not.exist') + }) + + it('discards raw edits when requested', function() { + openRawEditor() + rawContent().type('{selectall}{del}throwaway content') + cy.get('[data-cy="discardRawEditing"]').click() + cy.get('[data-text-el="raw-markdown-editor"]').should('not.exist') + cy.getContent().find('h1').should('contain.text', 'Hello') + cy.getContent().should('not.contain.text', 'throwaway content') + }) + + describe('concurrent changes', function() { + // Mimic a remote collaborator editing the live document while we are + // detached in raw mode by mutating the still-connected rich editor + // directly — the same effect a remote yjs update would have. + const simulateConcurrentEdit = function(text) { + return cy.window().then((win) => { + const component = [...win.OCA.Text.editorComponents].find((c) => c.rawEditing) + expect(component, 'component in raw editing mode').to.exist + component.editor.commands.insertContentAt(1, text) + }) + } + + const forkAndDiverge = function() { + openRawEditor() + simulateConcurrentEdit('CONCURRENT ') + rawContent().type('{selectall}{del}mine rewrite') + cy.get('[data-cy="exitRawEditing"]').click() + // Drift detected: the collision resolver is shown instead of + // silently overwriting the concurrent change. + cy.get('#resolve-conflicts', { timeout: 10000 }).should('exist') + } + + it('keeps the raw edits when choosing the local version', function() { + forkAndDiverge() + cy.get('[data-cy="useReaderVersion"]').click() + cy.get('#resolve-conflicts').should('not.exist') + cy.getContent().should('contain.text', 'mine rewrite') + cy.getContent().should('not.contain.text', 'CONCURRENT') + }) + + it('keeps the concurrent version when discarding the raw edits', function() { + forkAndDiverge() + cy.get('[data-cy="useEditorVersion"]').click() + cy.get('#resolve-conflicts').should('not.exist') + cy.getContent().should('contain.text', 'CONCURRENT') + cy.getContent().should('not.contain.text', 'mine rewrite') + }) + }) +}) diff --git a/src/components/CollaborativeEditor.vue b/src/components/CollaborativeEditor.vue index b4d9fcfbe21..35aeafbf035 100644 --- a/src/components/CollaborativeEditor.vue +++ b/src/components/CollaborativeEditor.vue @@ -15,7 +15,7 @@ + + v-if="isRichEditor && contentLoaded && !isRichWorkspace && !rawEditing" /> import('./RichTextReader.vue')), PlainTextReader: defineAsyncComponent(() => import('./PlainTextReader.vue')), @@ -312,7 +319,7 @@ export default defineComponent({ provideEditorHeadings(editor) - const { setEditable, updateUser } = useEditorMethods(editor) + const { setContent, setEditable, updateUser } = useEditorMethods(editor) const serialize = isRichEditor ? () => createMarkdownSerializer(editor.schema).serialize(editor.state.doc) @@ -325,6 +332,24 @@ export default defineComponent({ ydoc, ) + // Per-user raw Markdown editing. Only meaningful when the rich editor is + // in use — when `rich_editing_enabled` is off the whole instance is + // already raw (isRichEditor === false), so the toggle stays hidden. + const canRawEdit = computed(() => isRichEditor && !isRichWorkspace && !isPublic) + const { + rawEditing, + rawInitialMarkdown, + rawMarkdown, + rawConflict, + resolveRawConflict, + } = provideRawEditing({ + serialize, + setContent, + setEditable, + save: () => saveService.save(), + canRawEdit, + }) + const syncProvider = shallowRef(null) const editorReady = new Promise((resolve) => { @@ -354,6 +379,11 @@ export default defineComponent({ language, lowlightLoaded, displayConnectionIssue, + rawEditing, + rawInitialMarkdown, + rawMarkdown, + rawConflict, + resolveRawConflict, saveService, serialize, setDirty, @@ -395,14 +425,32 @@ export default defineComponent({ hasSyncCollision() { return ( - Boolean(this.indexedDbConflictContent) + Boolean(this.rawConflict) + || Boolean(this.indexedDbConflictContent) || (this.syncError && this.syncError.type === ERROR_TYPE.SAVE_COLLISION) ) }, otherVersion() { - return this.indexedDbConflictContent || this.syncError.data.outsideChange + // For a raw-editing conflict the editor still holds the live + // (remote) document, and `rawConflict` holds the user's raw edits — + // so it maps to the "local" reader source in CollisionResolveDialog. + return ( + this.rawConflict + || this.indexedDbConflictContent + || this.syncError.data.outsideChange + ) + }, + + conflictReaderSource() { + return this.rawConflict || this.indexedDbConflictContent + ? 'local' + : 'server' + }, + + rawUnsaved() { + return this.rawEditing && this.rawMarkdown !== this.rawInitialMarkdown }, hasDocumentParameters() { @@ -468,6 +516,14 @@ export default defineComponent({ } }, + rawUnsaved(val) { + if (val) { + window.addEventListener('beforeunload', this.warnBeforeUnload) + } else { + window.removeEventListener('beforeunload', this.warnBeforeUnload) + } + }, + displayConnectionIssue(val) { if (val) { this.$emit('syncService:error') @@ -547,6 +603,7 @@ export default defineComponent({ window.removeEventListener('afterprint', this.preparePrinting) } unsubscribe('text:keyboard:save', this.onKeyboardSave) + window.removeEventListener('beforeunload', this.warnBeforeUnload) const timeout = new Promise((resolve) => setTimeout(resolve, 2000)) await Promise.any([timeout, this.saveWhenDirty()]) await this.close() @@ -763,7 +820,11 @@ export default defineComponent({ // ignore initial loading and other automated changes before first user change if (this.editor.can().undo() || this.editor.can().redo()) { this.setDirty(state.dirty) - this.saveService.autosave() + // While raw editing, the rich editor is detached and only + // absorbs remote changes — never autosave/push from it. + if (!this.rawEditing) { + this.saveService.autosave() + } } } else { this.setDirty(state.dirty) @@ -923,6 +984,13 @@ export default defineComponent({ this.saveService.saveViaSendBeacon() }, + warnBeforeUnload(event) { + // Raw edits live in a detached buffer that cannot be flushed safely + // on unload, so prompt the user instead of losing them silently. + event.preventDefault() + event.returnValue = '' + }, + checkIndexedDbConflict() { // Only check if we're not already showing a conflict (e.g. when server API reported one) if (this.hasSyncCollision) { @@ -947,6 +1015,7 @@ export default defineComponent({ resolved() { localStorage.removeItem(this.indexedDbConflictKey) this.indexedDbConflictContent = '' + this.resolveRawConflict() }, }, }) diff --git a/src/components/Editor/RawMarkdownEditor.vue b/src/components/Editor/RawMarkdownEditor.vue new file mode 100644 index 00000000000..35cbfde702f --- /dev/null +++ b/src/components/Editor/RawMarkdownEditor.vue @@ -0,0 +1,144 @@ + + + + + + + + {{ t('text', 'Editing Markdown source') }} + + + + {{ t('text', 'Discard changes') }} + + + {{ t('text', 'Rich text') }} + + + + + + + + + + + + diff --git a/src/components/Menu/MenuBar.vue b/src/components/Menu/MenuBar.vue index 7aebf7be5f1..f95382b5616 100644 --- a/src/components/Menu/MenuBar.vue +++ b/src/components/Menu/MenuBar.vue @@ -19,7 +19,7 @@ + @@ -77,10 +78,12 @@ import ActionFormattingHelp from './ActionFormattingHelp.vue' import ActionList from './ActionList.vue' import ActionSingle from './ActionSingle.vue' import CharacterCount from './CharacterCount.vue' +import RawEditingToggle from './RawEditingToggle.vue' import WidthToggle from './WidthToggle.vue' import { useEditor } from '../../composables/useEditor.ts' import { useEditorFlags } from '../../composables/useEditorFlags.ts' import { useMenuEntries } from '../../composables/useMenuEntries.ts' +import { useRawEditing } from '../../composables/useRawEditing.ts' import { useIsMobileMixin } from '../Editor.provider.ts' import { DotsHorizontal } from '../icons.js' import { MENU_ID } from './MenuBar.provider.js' @@ -95,6 +98,7 @@ export default { HelpModal, NcActionSeparator, CharacterCount, + RawEditingToggle, WidthToggle, }, @@ -129,6 +133,7 @@ export default { setup() { const editor = useEditor() const { isPublic, isRichEditor, isRichWorkspace } = useEditorFlags() + const { rawEditing } = useRawEditing() const { assistantMenuEntries, menuEntries, readOnlyDoneEntries } = useMenuEntries() const menubar = ref() @@ -139,6 +144,7 @@ export default { isPublic, isRichEditor, isRichWorkspace, + rawEditing, menubar, menuEntries, readOnlyDoneEntries, diff --git a/src/components/Menu/RawEditingToggle.vue b/src/components/Menu/RawEditingToggle.vue new file mode 100644 index 00000000000..55b1653583b --- /dev/null +++ b/src/components/Menu/RawEditingToggle.vue @@ -0,0 +1,32 @@ + + + + + + + + {{ t('text', 'Edit Markdown source') }} + + + + diff --git a/src/composables/useRawEditing.ts b/src/composables/useRawEditing.ts new file mode 100644 index 00000000000..f5b4290ae47 --- /dev/null +++ b/src/composables/useRawEditing.ts @@ -0,0 +1,169 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { InjectionKey, Ref } from 'vue' + +import { inject, provide, ref } from 'vue' +import { logger } from '../helpers/logger.ts' + +export interface RawEditingApi { + /** Whether the current user is editing the raw Markdown source. */ + rawEditing: Ref + /** Whether the raw editing toggle is available in this context. */ + canRawEdit: Ref + /** Markdown handed to the raw editor when it is opened. */ + rawInitialMarkdown: Ref + /** Current content of the raw editor, kept in sync via its `change` event. */ + rawMarkdown: Ref + /** + * Raw Markdown that could not be applied automatically because the live + * document drifted while it was being edited. Empty unless a conflict is + * pending resolution via CollisionResolveDialog. + */ + rawConflict: Ref + enterRawEditing: () => void + exitRawEditing: (options?: { discard?: boolean }) => Promise + /** Clear a pending raw conflict (once resolved). */ + resolveRawConflict: () => void + toggle: () => void +} + +interface Dependencies { + /** Serialize the live (rich) document to Markdown. */ + serialize: () => string + /** Replace the live document content from a Markdown string. */ + setContent: (content: string) => void + /** Toggle whether the live (rich) editor accepts input. */ + setEditable: (editable: boolean) => void + /** Persist the document. */ + save: () => Promise + /** Whether raw editing is allowed at all (markdown, not a workspace, …). */ + canRawEdit: Ref +} + +export const rawEditingKey = Symbol('editor:raw-editing') as InjectionKey + +/** + * Create the raw Markdown editing state machine and provide it to descendants. + * + * Raw editing runs the current user on a *detached* plain-text editor while the + * collaborative rich editor stays mounted and connected in the background. This + * keeps other collaborators unaffected (they never see the plain schema) at the + * cost of reconciling the raw buffer against the live document on return. + * + * @param deps dependencies from the collaborative editor + */ +export function provideRawEditing(deps: Dependencies): RawEditingApi { + const rawEditing = ref(false) + const rawInitialMarkdown = ref('') + const rawMarkdown = ref('') + const rawConflict = ref('') + // Markdown at the moment raw editing started, used to detect whether the + // live document drifted (concurrent edits) while it was being edited raw. + const forkSnapshot = ref('') + + const enterRawEditing = () => { + if (!deps.canRawEdit.value || rawEditing.value) { + return + } + const markdown = deps.serialize() + forkSnapshot.value = markdown + rawInitialMarkdown.value = markdown + rawMarkdown.value = markdown + // Freeze the background rich editor so it only absorbs remote changes. + deps.setEditable(false) + rawEditing.value = true + logger.debug('Entered raw Markdown editing') + } + + const exitRawEditing = async ({ discard = false } = {}) => { + if (!rawEditing.value) { + return + } + const mine = rawMarkdown.value + const base = forkSnapshot.value + // Re-enable and reveal the live rich editor before reconciling. + deps.setEditable(true) + rawEditing.value = false + + if (discard || mine === base) { + // Nothing to apply: the user either cancelled or never changed the + // source. Keep whatever the live document currently holds. + logger.debug('Left raw Markdown editing without changes', { discard }) + return + } + + const live = deps.serialize() + if (live === base) { + // No concurrent edits arrived while editing raw: apply directly. + deps.setContent(mine) + try { + await deps.save() + } catch (error) { + logger.error('Failed to save raw Markdown changes', { error }) + } + logger.debug('Applied raw Markdown changes') + } else { + // The live document drifted (other collaborators edited it) while we + // were detached. Hand both versions to CollisionResolveDialog and let + // the user pick, instead of silently clobbering their work. + rawConflict.value = mine + logger.debug('Raw Markdown editing conflicts with concurrent changes') + } + } + + const resolveRawConflict = () => { + rawConflict.value = '' + } + + const toggle = () => { + if (rawEditing.value) { + exitRawEditing() + } else { + enterRawEditing() + } + } + + const api: RawEditingApi = { + rawEditing, + canRawEdit: deps.canRawEdit, + rawInitialMarkdown, + rawMarkdown, + rawConflict, + enterRawEditing, + exitRawEditing, + resolveRawConflict, + toggle, + } + provide(rawEditingKey, api) + return api +} + +/** + * + */ +function noopApi(): RawEditingApi { + return { + rawEditing: ref(false), + canRawEdit: ref(false), + rawInitialMarkdown: ref(''), + rawMarkdown: ref(''), + rawConflict: ref(''), + enterRawEditing: () => {}, + exitRawEditing: async () => {}, + resolveRawConflict: () => {}, + toggle: () => {}, + } +} + +/** + * Inject the raw editing API. + * + * Falls back to an inert API when used outside of a collaborative editor (e.g. + * the plain editor or standalone readers) so consumers can be rendered anywhere. + */ +export function useRawEditing(): RawEditingApi { + return inject(rawEditingKey, undefined) ?? noopApi() +} diff --git a/src/tests/composables/useRawEditing.spec.ts b/src/tests/composables/useRawEditing.spec.ts new file mode 100644 index 00000000000..bc74637de2a --- /dev/null +++ b/src/tests/composables/useRawEditing.spec.ts @@ -0,0 +1,121 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { RawEditingApi } from '../../composables/useRawEditing.ts' + +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, ref } from 'vue' +import { provideRawEditing } from '../../composables/useRawEditing.ts' + +/** + * Mount a host component that provides the raw editing API and expose it along + * with the mocked dependencies so tests can drive the state machine directly. + * + * @param live markdown returned by `serialize()` when raw editing is left + */ +function setup(live: string) { + const deps = { + serialize: vi.fn(() => live), + setContent: vi.fn(), + setEditable: vi.fn(), + save: vi.fn(async () => {}), + canRawEdit: ref(true), + } + let api: RawEditingApi | undefined + const Host = defineComponent({ + template: '', + setup() { + api = provideRawEditing(deps) + return {} + }, + }) + mount(Host) + return { api: api as RawEditingApi, deps } +} + +describe('useRawEditing', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('freezes the rich editor when entering raw mode', () => { + const { api, deps } = setup('# Base') + deps.serialize.mockReturnValueOnce('# Base') + api.enterRawEditing() + expect(api.rawEditing.value).toBe(true) + expect(api.rawInitialMarkdown.value).toBe('# Base') + expect(deps.setEditable).toHaveBeenCalledWith(false) + }) + + it('does not enter raw mode when disabled', () => { + const { api, deps } = setup('# Base') + deps.canRawEdit.value = false + api.enterRawEditing() + expect(api.rawEditing.value).toBe(false) + expect(deps.setEditable).not.toHaveBeenCalled() + }) + + it('applies raw edits directly when the document did not drift', async () => { + const { api, deps } = setup('# Base') + deps.serialize.mockReturnValueOnce('# Base') // fork snapshot on enter + api.enterRawEditing() + api.rawMarkdown.value = '# Edited' + deps.serialize.mockReturnValueOnce('# Base') // live doc unchanged on exit + await api.exitRawEditing() + expect(deps.setContent).toHaveBeenCalledWith('# Edited') + expect(deps.save).toHaveBeenCalled() + expect(api.rawConflict.value).toBe('') + expect(api.rawEditing.value).toBe(false) + expect(deps.setEditable).toHaveBeenLastCalledWith(true) + }) + + it('raises a conflict instead of clobbering concurrent edits', async () => { + const { api, deps } = setup('# Base') + deps.serialize.mockReturnValueOnce('# Base') // fork snapshot on enter + api.enterRawEditing() + api.rawMarkdown.value = '# Edited' + deps.serialize.mockReturnValueOnce('# Base\n\nremote change') // drifted + await api.exitRawEditing() + expect(deps.setContent).not.toHaveBeenCalled() + expect(deps.save).not.toHaveBeenCalled() + expect(api.rawConflict.value).toBe('# Edited') + expect(api.rawEditing.value).toBe(false) + }) + + it('does nothing when the source was not changed', async () => { + const { api, deps } = setup('# Base') + deps.serialize.mockReturnValueOnce('# Base') + api.enterRawEditing() + // rawMarkdown left equal to the initial snapshot + await api.exitRawEditing() + expect(deps.setContent).not.toHaveBeenCalled() + expect(deps.save).not.toHaveBeenCalled() + expect(api.rawConflict.value).toBe('') + }) + + it('discards raw edits without applying them', async () => { + const { api, deps } = setup('# Base') + deps.serialize.mockReturnValueOnce('# Base') + api.enterRawEditing() + api.rawMarkdown.value = '# Edited' + await api.exitRawEditing({ discard: true }) + expect(deps.setContent).not.toHaveBeenCalled() + expect(deps.save).not.toHaveBeenCalled() + expect(api.rawConflict.value).toBe('') + }) + + it('clears a pending conflict once resolved', async () => { + const { api, deps } = setup('# Base') + deps.serialize.mockReturnValueOnce('# Base') + api.enterRawEditing() + api.rawMarkdown.value = '# Edited' + deps.serialize.mockReturnValueOnce('# Drifted') + await api.exitRawEditing() + expect(api.rawConflict.value).toBe('# Edited') + api.resolveRawConflict() + expect(api.rawConflict.value).toBe('') + }) +})