From 25e1d42aae5dabfd7b81c6b7c0529d37b1edae00 Mon Sep 17 00:00:00 2001 From: Gordon Myers Date: Tue, 21 Jul 2026 22:24:49 -0500 Subject: [PATCH] feat: add per-user raw Markdown editing (#3570) Add an "Edit Markdown source" toggle that switches the current user into a detached plain-text editor showing the document's raw Markdown. Other collaborators are unaffected: the collaborative rich editor stays mounted and connected in the background, so the shared document never sees the plain-text schema. On return, the edited source is applied to the live document. If the document drifted in the meantime (concurrent edits from other users), the existing CollisionResolveDialog is reused so the user can keep their raw version or the remote one instead of silently clobbering either. - useRawEditing: enter/exit state machine with drift detection - RawMarkdownEditor: detached plain editor (no Collaboration extension) - RawEditingToggle: overflow-menu entry, gated to rich Markdown editing (hidden when rich_editing_enabled is off, i.e. the whole instance is raw) - suppress autosave while detached; warn on unload with unsaved raw edits - unit tests for the state machine and Cypress e2e covering the round-trip, discard, and both conflict-resolution branches Signed-off-by: Gordon Myers Co-Authored-By: Claude Opus 4.8 (1M context) --- cypress/e2e/RawEditing.spec.js | 99 ++++++++++++ src/components/CollaborativeEditor.vue | 85 +++++++++- src/components/Editor/RawMarkdownEditor.vue | 144 +++++++++++++++++ src/components/Menu/MenuBar.vue | 8 +- src/components/Menu/RawEditingToggle.vue | 32 ++++ src/composables/useRawEditing.ts | 169 ++++++++++++++++++++ src/tests/composables/useRawEditing.spec.ts | 121 ++++++++++++++ 7 files changed, 649 insertions(+), 9 deletions(-) create mode 100644 cypress/e2e/RawEditing.spec.js create mode 100644 src/components/Editor/RawMarkdownEditor.vue create mode 100644 src/components/Menu/RawEditingToggle.vue create mode 100644 src/composables/useRawEditing.ts create mode 100644 src/tests/composables/useRawEditing.spec.ts 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 @@