From 0e38d34b06bfbec765d2b68beed4578b2b7d7b0b Mon Sep 17 00:00:00 2001 From: Alexander Ackermann Date: Thu, 27 Aug 2026 00:37:25 +0200 Subject: [PATCH 1/6] feat(text-editor): show collaboration-ready indicator in toolbar --- packages/web-app-text-editor/src/App.vue | 17 ++++++-- .../tests/unit/app.spec.ts | 18 +++++++- .../components/AppTemplates/AppWrapper.vue | 1 + .../src/components/AppTemplates/types.ts | 9 +++- .../editor/components/TextEditorToolbar.vue | 18 ++++++++ .../src/editor/composables/useTextEditor.ts | 3 ++ packages/web-pkg/src/editor/types.ts | 5 +++ .../AppTemplates/AppWrapper.spec.ts | 35 ++++++++++++++- .../components/TextEditorToolbar.spec.ts | 43 ++++++++++++++++++- .../editor/composables/useTextEditor.spec.ts | 16 +++++++ 10 files changed, 157 insertions(+), 8 deletions(-) diff --git a/packages/web-app-text-editor/src/App.vue b/packages/web-app-text-editor/src/App.vue index 6a1bad7f312..7f534b9fd66 100644 --- a/packages/web-app-text-editor/src/App.vue +++ b/packages/web-app-text-editor/src/App.vue @@ -10,7 +10,10 @@ diff --git a/packages/web-app-text-editor/tests/unit/app.spec.ts b/packages/web-app-text-editor/tests/unit/app.spec.ts index 811250964c3..0e844213464 100644 --- a/packages/web-app-text-editor/tests/unit/app.spec.ts +++ b/packages/web-app-text-editor/tests/unit/app.spec.ts @@ -1,10 +1,11 @@ import { PartialComponentProps, defaultPlugins, mount } from '@opencloud-eu/web-test-helpers' import { mock } from 'vitest-mock-extended' -import { defineComponent, shallowRef, toRaw } from 'vue' +import { defineComponent, shallowRef, toRaw, toValue } from 'vue' import * as Y from 'yjs' import { Awareness } from 'y-protocols/awareness' import type { Editor } from '@tiptap/vue-3' import type { Resource } from '@opencloud-eu/web-client' +import type { YjsStatus } from '@opencloud-eu/web-pkg' import type { TextEditorOptions } from '@opencloud-eu/web-pkg/editor' import App from '../../src/App.vue' @@ -82,10 +83,24 @@ describe('Text editor app', () => { false ) }) + + it('passes the yjs status into useTextEditor', () => { + getWrapper({ yjsStatus: 'connected' }) + expect(toValue(lastOptions().yjsStatus)).toBe('connected') + }) + + it('updates useTextEditor options when yjsStatus changes after mount', async () => { + const { wrapper } = getWrapper({ yjsStatus: 'connecting' }) + expect(toValue(lastOptions().yjsStatus)).toBe('connecting') + + await wrapper.setProps({ yjsStatus: 'connected' }) + expect(toValue(lastOptions().yjsStatus)).toBe('connected') + }) }) function getWrapper(props: PartialComponentProps = {}) { const ydoc = (props.ydoc as Y.Doc) ?? new Y.Doc() + const yjsStatus = (props.yjsStatus as YjsStatus | null | undefined) ?? null return { wrapper: mount(App, { props: { @@ -93,6 +108,7 @@ function getWrapper(props: PartialComponentProps = {}) { isReadOnly: false, resource: mock({ extension: 'txt', mimeType: 'text/plain' }), awareness: new Awareness(ydoc), + yjsStatus, ...props, ydoc }, diff --git a/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue b/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue index 35ad8293544..61af8869660 100644 --- a/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue +++ b/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue @@ -840,6 +840,7 @@ const slotAttrs = computed(() => ( // session being synced and hydrated. Always null for other apps. ydoc: unref(yjsSession?.ydoc) ?? null, awareness: unref(yjsSession?.awareness) ?? null, + yjsStatus: unref(yjsSession?.status) ?? null, 'onUpdate:resource': (value: Resource) => { space.value = unref(unref(currentFileContext).space) diff --git a/packages/web-pkg/src/components/AppTemplates/types.ts b/packages/web-pkg/src/components/AppTemplates/types.ts index e7b7860699b..894d934a9fa 100644 --- a/packages/web-pkg/src/components/AppTemplates/types.ts +++ b/packages/web-pkg/src/components/AppTemplates/types.ts @@ -3,6 +3,7 @@ import { AppConfigObject } from '../../apps/types' import { Ref } from 'vue' import type * as Y from 'yjs' import type { Awareness } from 'y-protocols/awareness' +import type { YjsStatus } from '../../composables/yjs' import type { AppFileHandlingResult, AppFolderHandlingResult, @@ -63,6 +64,11 @@ export interface AppWrapperSlotProps { ydoc: Y.Doc | null /** Set once the Yjs session is synced and hydrated, else null. */ awareness: Awareness | null + /** + * Current collaboration transport status. Null when the wrapped app did not + * opt into Yjs. + */ + yjsStatus: YjsStatus | null } /** @@ -89,7 +95,8 @@ export type EditorSlotProps = Pick< > /** Editors opting into collaborative editing via {@link YjsOptions}. */ -export type YjsEditorSlotProps = EditorSlotProps & Pick +export type YjsEditorSlotProps = EditorSlotProps & + Pick /** Apps that render the file from a URL instead of its body. */ export type ViewerSlotProps = Pick diff --git a/packages/web-pkg/src/editor/components/TextEditorToolbar.vue b/packages/web-pkg/src/editor/components/TextEditorToolbar.vue index fa9f133f01d..458f0ce6ed7 100644 --- a/packages/web-pkg/src/editor/components/TextEditorToolbar.vue +++ b/packages/web-pkg/src/editor/components/TextEditorToolbar.vue @@ -149,6 +149,18 @@ +
() const textEditor = inject('textEditor')! +const { $gettext } = useGettext() const scrollContainerRef = useTemplateRef('scrollContainer') const canScrollLeft = ref(false) @@ -284,6 +298,10 @@ const visible = computed(() => { } return !!unref(textEditor.editor) }) +const collaborationReadyLabel = computed(() => $gettext('Collaboration ready')) +const showCollaborationReadyIndicator = computed( + () => unref(textEditor.collaborationStatus) === 'connected' +) const isSourceMode = computed(() => unref(textEditor.state.sourceMode)) const sourceModeEnabledActionIds = ['source-mode', 'menu-zoom', 'zoom-in', 'zoom-out', 'zoom-reset'] diff --git a/packages/web-pkg/src/editor/composables/useTextEditor.ts b/packages/web-pkg/src/editor/composables/useTextEditor.ts index f5e7c82733e..c6e8bb0cb0c 100644 --- a/packages/web-pkg/src/editor/composables/useTextEditor.ts +++ b/packages/web-pkg/src/editor/composables/useTextEditor.ts @@ -8,6 +8,7 @@ import type { Awareness } from 'y-protocols/awareness' import type { ShallowRef } from 'vue' import type { Editor } from '@tiptap/vue-3' import type { Resource } from '@opencloud-eu/web-client' +import type { YjsStatus } from '../../composables/yjs' import type { TextEditorOptions, TextEditorInstance, @@ -67,6 +68,7 @@ export function useTextEditor(options: TextEditorOptions): TextEditorInstance { const contentType = ref(options.contentType) const readonly = computed(() => toValue(options.readonly) ?? false) + const collaborationStatus = computed(() => toValue(options.yjsStatus) ?? null) const strategy = resolveStrategy(options.contentType, state) const yjsFragment = options.ydocFragment ?? DEFAULT_YDOC_FRAGMENT @@ -282,6 +284,7 @@ export function useTextEditor(options: TextEditorOptions): TextEditorInstance { editor, contentType, readonly, + collaborationStatus, actionGroups: editorActionGroups, getContent, setContent, diff --git a/packages/web-pkg/src/editor/types.ts b/packages/web-pkg/src/editor/types.ts index 8e1cdf8ed2a..603ef4dfb1a 100644 --- a/packages/web-pkg/src/editor/types.ts +++ b/packages/web-pkg/src/editor/types.ts @@ -4,6 +4,7 @@ import type { Resource } from '@opencloud-eu/web-client' import type { Editor } from '@tiptap/vue-3' import type * as Y from 'yjs' import type { Awareness } from 'y-protocols/awareness' +import type { YjsStatus } from '../composables/yjs' import type { EditorActionGroup } from './composables' export type ContentType = 'plain-text' | 'markdown' | 'html' | 'tiptap-json' @@ -52,6 +53,8 @@ export interface TextEditorOptions { * is not also set. */ awareness?: Awareness + /** Current transport status of the hosting Yjs session, if any. */ + yjsStatus?: MaybeRefOrGetter } export interface TextEditorLinkPanelRequest { @@ -73,6 +76,8 @@ export interface TextEditorInstance { contentType: Ref /** Derived from the caller's `readonly` option; follows it while mounted. */ readonly: Ref + /** Current transport status of the hosting Yjs session, if any. */ + collaborationStatus: Ref actionGroups(): EditorActionGroup[] getContent(): string setContent(value: string): void diff --git a/packages/web-pkg/tests/unit/components/AppTemplates/AppWrapper.spec.ts b/packages/web-pkg/tests/unit/components/AppTemplates/AppWrapper.spec.ts index cab1bd4c93e..f3e344220b8 100644 --- a/packages/web-pkg/tests/unit/components/AppTemplates/AppWrapper.spec.ts +++ b/packages/web-pkg/tests/unit/components/AppTemplates/AppWrapper.spec.ts @@ -81,6 +81,7 @@ function setup({ const beginSave = vi.fn() const serializeMerged = vi.fn().mockResolvedValue(mergedContent) const isLockedForReload = ref(false) + const statusRef = ref(status) const currentFileContext = ref(mock({ space: mock(), path: '/a.md' })) // Deferred so the test controls when each half of the load completes. let resolveInfo: (r: Resource) => void @@ -107,7 +108,7 @@ function setup({ sessionOptions = options return mock({ isReady: ref(true) as any, - status: ref(status) as any, + status: statusRef as any, // Auto-mocked refs are truthy, which would fold into `effectiveReadOnly` // and hard-wire `isDirty` to false. isLockedForReload: isLockedForReload as any, @@ -168,6 +169,8 @@ function setup({ beginSave, serializeMerged, currentContent: () => slotProps?.currentContent, + yjsStatus: () => slotProps?.yjsStatus, + statusRef, async edit(content: string) { slotProps['onUpdate:currentContent'](content) await nextTick() @@ -243,6 +246,36 @@ describe('AppWrapper — Yjs session gate', () => { await s.resolveContent('content of b') expect(s.isEnabled()).toBe(true) }) + + it('passes the current yjs status to the wrapped component slot props', async () => { + const s = setup({ status: 'connected' }) + await nextTick() + await s.resolveResource(FILE_A) + await s.resolveContent('content of a') + + expect(s.yjsStatus()).toBe('connected') + }) + + it('passes null yjs status when the wrapped app did not opt into yjs', async () => { + const s = setup({ yjsEnabled: false }) + await nextTick() + await s.resolveResource(FILE_A) + await s.resolveContent('content of a') + + expect(s.yjsStatus()).toBeNull() + }) + + it('updates slot yjs status when the session status changes', async () => { + const s = setup({ status: 'connecting' }) + await nextTick() + await s.resolveResource(FILE_A) + await s.resolveContent('content of a') + expect(s.yjsStatus()).toBe('connecting') + + s.statusRef.value = 'connected' + await nextTick() + expect(s.yjsStatus()).toBe('connected') + }) }) describe('AppWrapper — resource identity across a save', () => { diff --git a/packages/web-pkg/tests/unit/editor/components/TextEditorToolbar.spec.ts b/packages/web-pkg/tests/unit/editor/components/TextEditorToolbar.spec.ts index 6116517746a..17d8cf45bd8 100644 --- a/packages/web-pkg/tests/unit/editor/components/TextEditorToolbar.spec.ts +++ b/packages/web-pkg/tests/unit/editor/components/TextEditorToolbar.spec.ts @@ -5,12 +5,18 @@ import TextEditorToolbar from '../../../../src/editor/components/TextEditorToolb import type { TextEditorInstance } from '../../../../src/editor/types' import type { EditorAction } from '../../../../src/editor/composables' +vi.mock('vue3-gettext', () => ({ + useGettext: () => ({ $gettext: (value: string) => value }) +})) + function mountToolbar( sourceMode = false, contentType: 'markdown' | 'html' = 'markdown', - includeSearchAction = false + includeSearchAction = false, + collaborationStatus: 'connecting' | 'connected' | 'disconnected' | 'local' | null = null ) { const showSpy = vi.fn() + const collaborationStatusRef = ref(collaborationStatus) const actions: EditorAction[] = [ { @@ -41,6 +47,7 @@ function mountToolbar( editor: ref({}), contentType: ref<'markdown' | 'html'>(contentType), readonly: ref(false), + collaborationStatus: collaborationStatusRef, state: { sourceMode: ref(sourceMode), editorZoom: ref(100) }, isFocused: computed(() => isFocusedRef.value), actionGroups: () => [ @@ -79,7 +86,7 @@ function mountToolbar( } }) - return { wrapper, textEditor, isFocusedRef, showSpy } + return { wrapper, textEditor, isFocusedRef, showSpy, collaborationStatusRef } } describe('TextEditorToolbar', () => { @@ -173,4 +180,36 @@ describe('TextEditorToolbar', () => { expect(showSpy).not.toHaveBeenCalled() wrapper.unmount() }) + + it('shows the collaboration-ready indicator only when status is connected', () => { + const connected = mountToolbar(false, 'markdown', false, 'connected') + expect(connected.wrapper.find('.text-editor-toolbar-collaboration-ready').exists()).toBe(true) + connected.wrapper.unmount() + + const local = mountToolbar(false, 'markdown', false, 'local') + expect(local.wrapper.find('.text-editor-toolbar-collaboration-ready').exists()).toBe(false) + local.wrapper.unmount() + }) + + it('sets an aria-label on the collaboration-ready indicator', () => { + const { wrapper } = mountToolbar(false, 'markdown', false, 'connected') + expect(wrapper.find('.text-editor-toolbar-collaboration-ready').attributes('aria-label')).toBe( + 'Collaboration ready' + ) + wrapper.unmount() + }) + + it('reacts to collaboration status changes after mount', async () => { + const { wrapper, collaborationStatusRef } = mountToolbar(false, 'markdown', false, null) + expect(wrapper.find('.text-editor-toolbar-collaboration-ready').exists()).toBe(false) + + collaborationStatusRef.value = 'connected' + await wrapper.vm.$nextTick() + expect(wrapper.find('.text-editor-toolbar-collaboration-ready').exists()).toBe(true) + + collaborationStatusRef.value = 'disconnected' + await wrapper.vm.$nextTick() + expect(wrapper.find('.text-editor-toolbar-collaboration-ready').exists()).toBe(false) + wrapper.unmount() + }) }) diff --git a/packages/web-pkg/tests/unit/editor/composables/useTextEditor.spec.ts b/packages/web-pkg/tests/unit/editor/composables/useTextEditor.spec.ts index a40083949cd..2f7500b0412 100644 --- a/packages/web-pkg/tests/unit/editor/composables/useTextEditor.spec.ts +++ b/packages/web-pkg/tests/unit/editor/composables/useTextEditor.spec.ts @@ -30,6 +30,22 @@ describe('useTextEditor', () => { expect(result.readonly.value).toBe(true) }) + it('exposes collaborationStatus as ref', () => { + const { result } = createEditor({ yjsStatus: 'connected' }) + expect(result.collaborationStatus.value).toBe('connected') + }) + + it('follows a collaboration status getter that flips after setup', async () => { + const status = ref<'connecting' | 'connected'>('connecting') + const { result } = createEditor({ yjsStatus: () => unref(status) }) + expect(result.collaborationStatus.value).toBe('connecting') + + status.value = 'connected' + await nextTick() + + expect(result.collaborationStatus.value).toBe('connected') + }) + // Regression: `readonly` was snapshotted during setup, so a collaborative // session turning read-only after mount (locking the room on an app-version // mismatch, for one) hid the toolbar but left ProseMirror editable. The user From 9cb0c3578cee53a3e6dd1cdb6e0f28b0b472c4d0 Mon Sep 17 00:00:00 2001 From: Alexander Ackermann Date: Thu, 27 Aug 2026 00:47:45 +0200 Subject: [PATCH 2/6] feat(text-editor): use wifi icon for collaboration-ready status --- .../src/editor/components/TextEditorToolbar.vue | 10 +++++----- .../web-pkg/src/editor/composables/useTextEditor.ts | 5 ++--- packages/web-pkg/src/editor/types.ts | 2 +- .../unit/editor/components/TextEditorToolbar.spec.ts | 2 +- .../unit/editor/composables/useTextEditor.spec.ts | 10 +++++----- 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/web-pkg/src/editor/components/TextEditorToolbar.vue b/packages/web-pkg/src/editor/components/TextEditorToolbar.vue index 458f0ce6ed7..f4b90c05cce 100644 --- a/packages/web-pkg/src/editor/components/TextEditorToolbar.vue +++ b/packages/web-pkg/src/editor/components/TextEditorToolbar.vue @@ -153,13 +153,13 @@ v-if="showCollaborationReadyIndicator" v-oc-tooltip="collaborationReadyLabel" class="text-editor-toolbar-collaboration-ready ml-2 inline-flex shrink-0 items-center" - role="img" :aria-label="collaborationReadyLabel" >
{ }) const collaborationReadyLabel = computed(() => $gettext('Collaboration ready')) const showCollaborationReadyIndicator = computed( - () => unref(textEditor.collaborationStatus) === 'connected' + () => unref(textEditor.yjsStatus) === 'connected' ) const isSourceMode = computed(() => unref(textEditor.state.sourceMode)) diff --git a/packages/web-pkg/src/editor/composables/useTextEditor.ts b/packages/web-pkg/src/editor/composables/useTextEditor.ts index c6e8bb0cb0c..cc935b3ddb0 100644 --- a/packages/web-pkg/src/editor/composables/useTextEditor.ts +++ b/packages/web-pkg/src/editor/composables/useTextEditor.ts @@ -8,7 +8,6 @@ import type { Awareness } from 'y-protocols/awareness' import type { ShallowRef } from 'vue' import type { Editor } from '@tiptap/vue-3' import type { Resource } from '@opencloud-eu/web-client' -import type { YjsStatus } from '../../composables/yjs' import type { TextEditorOptions, TextEditorInstance, @@ -68,7 +67,7 @@ export function useTextEditor(options: TextEditorOptions): TextEditorInstance { const contentType = ref(options.contentType) const readonly = computed(() => toValue(options.readonly) ?? false) - const collaborationStatus = computed(() => toValue(options.yjsStatus) ?? null) + const yjsStatus = computed(() => toValue(options.yjsStatus) ?? null) const strategy = resolveStrategy(options.contentType, state) const yjsFragment = options.ydocFragment ?? DEFAULT_YDOC_FRAGMENT @@ -284,7 +283,7 @@ export function useTextEditor(options: TextEditorOptions): TextEditorInstance { editor, contentType, readonly, - collaborationStatus, + yjsStatus, actionGroups: editorActionGroups, getContent, setContent, diff --git a/packages/web-pkg/src/editor/types.ts b/packages/web-pkg/src/editor/types.ts index 603ef4dfb1a..c5cb4997f19 100644 --- a/packages/web-pkg/src/editor/types.ts +++ b/packages/web-pkg/src/editor/types.ts @@ -77,7 +77,7 @@ export interface TextEditorInstance { /** Derived from the caller's `readonly` option; follows it while mounted. */ readonly: Ref /** Current transport status of the hosting Yjs session, if any. */ - collaborationStatus: Ref + yjsStatus: Ref actionGroups(): EditorActionGroup[] getContent(): string setContent(value: string): void diff --git a/packages/web-pkg/tests/unit/editor/components/TextEditorToolbar.spec.ts b/packages/web-pkg/tests/unit/editor/components/TextEditorToolbar.spec.ts index 17d8cf45bd8..49912831d1a 100644 --- a/packages/web-pkg/tests/unit/editor/components/TextEditorToolbar.spec.ts +++ b/packages/web-pkg/tests/unit/editor/components/TextEditorToolbar.spec.ts @@ -47,7 +47,7 @@ function mountToolbar( editor: ref({}), contentType: ref<'markdown' | 'html'>(contentType), readonly: ref(false), - collaborationStatus: collaborationStatusRef, + yjsStatus: collaborationStatusRef, state: { sourceMode: ref(sourceMode), editorZoom: ref(100) }, isFocused: computed(() => isFocusedRef.value), actionGroups: () => [ diff --git a/packages/web-pkg/tests/unit/editor/composables/useTextEditor.spec.ts b/packages/web-pkg/tests/unit/editor/composables/useTextEditor.spec.ts index 2f7500b0412..f3b89163bec 100644 --- a/packages/web-pkg/tests/unit/editor/composables/useTextEditor.spec.ts +++ b/packages/web-pkg/tests/unit/editor/composables/useTextEditor.spec.ts @@ -30,20 +30,20 @@ describe('useTextEditor', () => { expect(result.readonly.value).toBe(true) }) - it('exposes collaborationStatus as ref', () => { + it('exposes yjsStatus as ref', () => { const { result } = createEditor({ yjsStatus: 'connected' }) - expect(result.collaborationStatus.value).toBe('connected') + expect(result.yjsStatus.value).toBe('connected') }) - it('follows a collaboration status getter that flips after setup', async () => { + it('follows a yjs status getter that flips after setup', async () => { const status = ref<'connecting' | 'connected'>('connecting') const { result } = createEditor({ yjsStatus: () => unref(status) }) - expect(result.collaborationStatus.value).toBe('connecting') + expect(result.yjsStatus.value).toBe('connecting') status.value = 'connected' await nextTick() - expect(result.collaborationStatus.value).toBe('connected') + expect(result.yjsStatus.value).toBe('connected') }) // Regression: `readonly` was snapshotted during setup, so a collaborative From 23049d7dd452d27272251b703c06ec5673411d8e Mon Sep 17 00:00:00 2001 From: Alexander Ackermann Date: Thu, 27 Aug 2026 10:10:54 +0200 Subject: [PATCH 3/6] feat(text-editor): show disconnected collaboration status - Show collaboration status indicator for both 'connected' and 'disconnected' states - Connected: green wifi icon with "Collaboration ready" label - Disconnected: red wifi-off icon with "Collaboration disconnected" label - Hide indicator for 'local', 'connecting', and null states - Rename collaborationStatus to yjsStatus for consistency - Introduce YjsStatus enum to replace string literals --- .../src/composables/yjs/useYjsSession.ts | 15 ++++-- .../editor/components/TextEditorToolbar.vue | 46 ++++++++++++++---- .../components/TextEditorToolbar.spec.ts | 48 +++++++++++++++---- 3 files changed, 86 insertions(+), 23 deletions(-) diff --git a/packages/web-pkg/src/composables/yjs/useYjsSession.ts b/packages/web-pkg/src/composables/yjs/useYjsSession.ts index 9a897b23dd4..e06dee8901e 100644 --- a/packages/web-pkg/src/composables/yjs/useYjsSession.ts +++ b/packages/web-pkg/src/composables/yjs/useYjsSession.ts @@ -10,7 +10,14 @@ import { useGettext } from 'vue3-gettext' import { useAuthStore, useConfigStore } from '../piniaStores' import type { YjsAdapter } from './types' -export type YjsStatus = 'connecting' | 'connected' | 'disconnected' | 'local' +export const YjsStatus = { + Connecting: 'connecting', + Connected: 'connected', + Disconnected: 'disconnected', + Local: 'local' +} as const + +export type YjsStatus = (typeof YjsStatus)[keyof typeof YjsStatus] export interface YjsSessionOptions { /** The file the session is bound to. Its id forms the room name, its etag drives staleness detection. */ @@ -371,7 +378,7 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession { * the doc's update handler keeps calling `send()`. */ function stopProvider(prov: HocuspocusProvider | null) { - status.value = 'disconnected' + status.value = YjsStatus.Disconnected if (!prov) return try { prov.disconnect() @@ -872,7 +879,7 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession { pendingSaveStateVector = null if (!key) { - status.value = 'connecting' + status.value = YjsStatus.Connecting return } // Non-null whenever `key` is: the key embeds it. @@ -896,7 +903,7 @@ export function useYjsSession(options: YjsSessionOptions): YjsSession { // Local mode: standalone Awareness so editor bindings still see a // non-null instance; nobody else will ever join, which is the point. aw = new Awareness(doc) - status.value = 'local' + status.value = YjsStatus.Local void onProviderSynced(doc, null, aw) } diff --git a/packages/web-pkg/src/editor/components/TextEditorToolbar.vue b/packages/web-pkg/src/editor/components/TextEditorToolbar.vue index f4b90c05cce..e893cb1e94d 100644 --- a/packages/web-pkg/src/editor/components/TextEditorToolbar.vue +++ b/packages/web-pkg/src/editor/components/TextEditorToolbar.vue @@ -150,15 +150,16 @@
- +
@@ -191,6 +192,7 @@ import type { TextEditorInstance } from '../types' import type { EditorAction, EditorActionGroup } from '../composables' import { OcDrop } from '@opencloud-eu/design-system/components' import { Key, Modifier, useKeyboardActions } from '../../composables/keyboardActions' +import { YjsStatus } from '../../composables/yjs' const { actionsToDisplay = undefined, teleport = undefined } = defineProps<{ actionsToDisplay?: string[] @@ -298,11 +300,37 @@ const visible = computed(() => { } return !!unref(textEditor.editor) }) -const collaborationReadyLabel = computed(() => $gettext('Collaboration ready')) -const showCollaborationReadyIndicator = computed( - () => unref(textEditor.yjsStatus) === 'connected' + +const showCollaborationStatusIndicator = computed( + () => + unref(textEditor.yjsStatus) === YjsStatus.Connected || + unref(textEditor.yjsStatus) === YjsStatus.Disconnected ) +const collaborationStatusLabel = computed(() => { + if (unref(textEditor.yjsStatus) === YjsStatus.Connected) { + return $gettext('Collaboration ready') + } + if (unref(textEditor.yjsStatus) === YjsStatus.Disconnected) { + return $gettext('Collaboration disconnected') + } + return '' +}) + +const collaborationStatusIcon = computed(() => + unref(textEditor.yjsStatus) === YjsStatus.Disconnected ? 'wifi-off' : 'wifi' +) + +const collaborationStatusClasses = computed(() => { + if (unref(textEditor.yjsStatus) === YjsStatus.Connected) { + return 'border-green-700/20 bg-green-500/15 text-green-700' + } + if (unref(textEditor.yjsStatus) === YjsStatus.Disconnected) { + return 'border-red-700/20 bg-red-500/15 text-red-700' + } + return '' +}) + const isSourceMode = computed(() => unref(textEditor.state.sourceMode)) const sourceModeEnabledActionIds = ['source-mode', 'menu-zoom', 'zoom-in', 'zoom-out', 'zoom-reset'] diff --git a/packages/web-pkg/tests/unit/editor/components/TextEditorToolbar.spec.ts b/packages/web-pkg/tests/unit/editor/components/TextEditorToolbar.spec.ts index 49912831d1a..01e2c481740 100644 --- a/packages/web-pkg/tests/unit/editor/components/TextEditorToolbar.spec.ts +++ b/packages/web-pkg/tests/unit/editor/components/TextEditorToolbar.spec.ts @@ -181,35 +181,63 @@ describe('TextEditorToolbar', () => { wrapper.unmount() }) - it('shows the collaboration-ready indicator only when status is connected', () => { + it('shows the collaboration status indicator for connected and disconnected', () => { const connected = mountToolbar(false, 'markdown', false, 'connected') - expect(connected.wrapper.find('.text-editor-toolbar-collaboration-ready').exists()).toBe(true) + expect(connected.wrapper.find('.text-editor-toolbar-collaboration-status').exists()).toBe(true) connected.wrapper.unmount() + const disconnected = mountToolbar(false, 'markdown', false, 'disconnected') + expect(disconnected.wrapper.find('.text-editor-toolbar-collaboration-status').exists()).toBe( + true + ) + disconnected.wrapper.unmount() + const local = mountToolbar(false, 'markdown', false, 'local') - expect(local.wrapper.find('.text-editor-toolbar-collaboration-ready').exists()).toBe(false) + expect(local.wrapper.find('.text-editor-toolbar-collaboration-status').exists()).toBe(false) local.wrapper.unmount() + + const connecting = mountToolbar(false, 'markdown', false, 'connecting') + expect(connecting.wrapper.find('.text-editor-toolbar-collaboration-status').exists()).toBe( + false + ) + connecting.wrapper.unmount() }) - it('sets an aria-label on the collaboration-ready indicator', () => { + it('sets correct aria-label and icon for connected status', () => { const { wrapper } = mountToolbar(false, 'markdown', false, 'connected') - expect(wrapper.find('.text-editor-toolbar-collaboration-ready').attributes('aria-label')).toBe( - 'Collaboration ready' - ) + const indicator = wrapper.find('.text-editor-toolbar-collaboration-status') + expect(indicator.attributes('aria-label')).toBe('Collaboration ready') + expect(indicator.find('oc-icon-stub').attributes('name')).toBe('wifi') + wrapper.unmount() + }) + + it('sets correct aria-label and icon for disconnected status', () => { + const { wrapper } = mountToolbar(false, 'markdown', false, 'disconnected') + const indicator = wrapper.find('.text-editor-toolbar-collaboration-status') + expect(indicator.attributes('aria-label')).toBe('Collaboration disconnected') + expect(indicator.find('oc-icon-stub').attributes('name')).toBe('wifi-off') wrapper.unmount() }) it('reacts to collaboration status changes after mount', async () => { const { wrapper, collaborationStatusRef } = mountToolbar(false, 'markdown', false, null) - expect(wrapper.find('.text-editor-toolbar-collaboration-ready').exists()).toBe(false) + expect(wrapper.find('.text-editor-toolbar-collaboration-status').exists()).toBe(false) collaborationStatusRef.value = 'connected' await wrapper.vm.$nextTick() - expect(wrapper.find('.text-editor-toolbar-collaboration-ready').exists()).toBe(true) + const connectedIndicator = wrapper.find('.text-editor-toolbar-collaboration-status') + expect(connectedIndicator.exists()).toBe(true) + expect(connectedIndicator.find('oc-icon-stub').attributes('name')).toBe('wifi') collaborationStatusRef.value = 'disconnected' await wrapper.vm.$nextTick() - expect(wrapper.find('.text-editor-toolbar-collaboration-ready').exists()).toBe(false) + const disconnectedIndicator = wrapper.find('.text-editor-toolbar-collaboration-status') + expect(disconnectedIndicator.exists()).toBe(true) + expect(disconnectedIndicator.find('oc-icon-stub').attributes('name')).toBe('wifi-off') + + collaborationStatusRef.value = 'local' + await wrapper.vm.$nextTick() + expect(wrapper.find('.text-editor-toolbar-collaboration-status').exists()).toBe(false) wrapper.unmount() }) }) From 07f42c7908354f5273686594fa4828baa2b2fa95 Mon Sep 17 00:00:00 2001 From: Alexander Ackermann Date: Thu, 27 Aug 2026 10:22:17 +0200 Subject: [PATCH 4/6] feat(text-editor): add connecting status indicator - Show gray wifi icon with 'Connecting...' label during connection - Status indicator now shows: connected (green), disconnected (red), connecting (gray) - Only 'local' and null states hide the indicator --- .../editor/components/TextEditorToolbar.vue | 19 ++++++++++++++---- .../components/TextEditorToolbar.spec.ts | 20 ++++++++++++------- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/web-pkg/src/editor/components/TextEditorToolbar.vue b/packages/web-pkg/src/editor/components/TextEditorToolbar.vue index e893cb1e94d..2a78c4ef911 100644 --- a/packages/web-pkg/src/editor/components/TextEditorToolbar.vue +++ b/packages/web-pkg/src/editor/components/TextEditorToolbar.vue @@ -1,5 +1,6 @@