Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions cypress/e2e/RawEditing.spec.js
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
85 changes: 77 additions & 8 deletions src/components/CollaborativeEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<CollisionResolveDialog
v-if="isResolvingConflict"
:otherVersion="otherVersion"
:readerSource="indexedDbConflictContent ? 'local' : 'server'"
:readerSource="conflictReaderSource"
@resolved="resolved()" />
<EditorWrapper
v-if="displayed"
Expand Down Expand Up @@ -54,12 +54,16 @@
</MenuBar>
<div v-else class="menubar-placeholder" />
</template>
<RawMarkdownEditor
v-if="rawEditing"
:initialMarkdown="rawInitialMarkdown"
@change="rawMarkdown = $event" />
<ContentContainer
v-show="contentLoaded"
v-show="contentLoaded && !rawEditing"
ref="contentWrapper"
:readOnly="!editMode" />
<SuggestionsBar
v-if="isRichEditor && contentLoaded && !isRichWorkspace" />
v-if="isRichEditor && contentLoaded && !isRichWorkspace && !rawEditing" />
</MainContainer>
<Component
:is="isRichEditor ? 'RichTextReader' : 'PlainTextReader'"
Expand All @@ -85,14 +89,15 @@ import { loadState } from '@nextcloud/initial-state'
import { generateRemoteUrl } from '@nextcloud/router'
import { Collaboration } from '@tiptap/extension-collaboration'
import { useElementSize } from '@vueuse/core'
import { defineAsyncComponent, defineComponent, inject, provide, ref, shallowRef, watch } from 'vue'
import { computed, defineAsyncComponent, defineComponent, inject, provide, ref, shallowRef, watch } from 'vue'
import { Awareness } from 'y-protocols/awareness.js'
import { Doc, logUpdate } from 'yjs'
import CollisionResolveDialog from './CollisionResolveDialog.vue'
import ContentContainer from './Editor/ContentContainer.vue'
import DocumentStatus from './Editor/DocumentStatus.vue'
import EditorWrapper from './Editor/EditorWrapper.vue'
import MainContainer from './Editor/MainContainer.vue'
import RawMarkdownEditor from './Editor/RawMarkdownEditor.vue'
import SessionStatus from './Editor/SessionStatus.vue'
import MenuBar from './Menu/MenuBar.vue'
import ReadonlyBar from './Menu/ReadonlyBar.vue'
Expand All @@ -108,6 +113,7 @@ import { provideEditorWidth } from '../composables/useEditorWidth.ts'
import { provideFileProps } from '../composables/useFileProps.ts'
import { useIndexedDbProvider } from '../composables/useIndexedDbProvider.ts'
import { useOpenLinkHandler } from '../composables/useOpenLinkHandler.ts'
import { provideRawEditing } from '../composables/useRawEditing.ts'
import { provideSaveService } from '../composables/useSaveService.ts'
import { provideSyncService } from '../composables/useSyncService.ts'
import { useSyntaxHighlighting } from '../composables/useSyntaxHighlighting.ts'
Expand Down Expand Up @@ -145,6 +151,7 @@ export default defineComponent({
MainContainer,
ReadonlyBar,
ContentContainer,
RawMarkdownEditor,
MenuBar,
RichTextReader: defineAsyncComponent(() => import('./RichTextReader.vue')),
PlainTextReader: defineAsyncComponent(() => import('./PlainTextReader.vue')),
Expand Down Expand Up @@ -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)
Expand All @@ -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) => {
Expand Down Expand Up @@ -354,6 +379,11 @@ export default defineComponent({
language,
lowlightLoaded,
displayConnectionIssue,
rawEditing,
rawInitialMarkdown,
rawMarkdown,
rawConflict,
resolveRawConflict,
saveService,
serialize,
setDirty,
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -947,6 +1015,7 @@ export default defineComponent({
resolved() {
localStorage.removeItem(this.indexedDbConflictKey)
this.indexedDbConflictContent = ''
this.resolveRawConflict()
},
},
})
Expand Down
Loading
Loading