Skip to content
Merged
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
17 changes: 14 additions & 3 deletions packages/web-app-text-editor/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
<script setup lang="ts">
import { computed, toRef, unref } from 'vue'
import { useGettext } from 'vue3-gettext'
import type { YjsEditorSlotProps } from '@opencloud-eu/web-pkg'
import type { Resource } from '@opencloud-eu/web-client'
import type { YjsStatus } from '@opencloud-eu/web-pkg'
import type * as Y from 'yjs'
import type { Awareness } from 'y-protocols/awareness'
import {
useTextEditor,
TextEditorProvider,
Expand All @@ -20,7 +23,14 @@ import {
} from '@opencloud-eu/web-pkg/editor'
import { detectContentType } from './yjs'

const { ydoc, awareness, isReadOnly, resource } = defineProps<YjsEditorSlotProps>()
const { ydoc, awareness, isReadOnly, resource, yjsStatus } = defineProps<{
currentContent: string
isReadOnly: boolean
resource: Resource
ydoc: Y.Doc | null
awareness: Awareness | null
yjsStatus: YjsStatus | null
}>()

const { $gettext } = useGettext()

Expand All @@ -41,6 +51,7 @@ const textEditor = useTextEditor({
readonly: () => isReadOnly,
placeholder: unref(placeholder),
ydoc,
awareness
awareness,
yjsStatus: () => yjsStatus
})
</script>
18 changes: 17 additions & 1 deletion packages/web-app-text-editor/tests/unit/app.spec.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -82,17 +83,32 @@ 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<typeof App> = {}) {
const ydoc = (props.ydoc as Y.Doc) ?? new Y.Doc()
const yjsStatus = (props.yjsStatus as YjsStatus | null | undefined) ?? null
return {
wrapper: mount(App, {
props: {
currentContent: '',
isReadOnly: false,
resource: mock<Resource>({ extension: 'txt', mimeType: 'text/plain' }),
awareness: new Awareness(ydoc),
yjsStatus,
...props,
ydoc
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,7 @@ const slotAttrs = computed<AppWrapperSlotProps & AppWrapperSlotHandlers>(() => (
// 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)
Expand Down
9 changes: 8 additions & 1 deletion packages/web-pkg/src/components/AppTemplates/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}

/**
Expand All @@ -89,7 +95,8 @@ export type EditorSlotProps = Pick<
>

/** Editors opting into collaborative editing via {@link YjsOptions}. */
export type YjsEditorSlotProps = EditorSlotProps & Pick<AppWrapperSlotProps, 'ydoc' | 'awareness'>
export type YjsEditorSlotProps = EditorSlotProps &
Pick<AppWrapperSlotProps, 'ydoc' | 'awareness' | 'yjsStatus'>

/** Apps that render the file from a URL instead of its body. */
export type ViewerSlotProps = Pick<AppWrapperSlotProps, 'resource' | 'url'>
Expand Down
15 changes: 11 additions & 4 deletions packages/web-pkg/src/composables/yjs/useYjsSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand All @@ -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)
}

Expand Down
54 changes: 54 additions & 0 deletions packages/web-pkg/src/editor/components/TextEditorToolbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,19 @@
</oc-button>
</template>
</div>
<div
v-if="showCollaborationStatusIndicator"
v-oc-tooltip="collaborationStatusLabel"
class="text-editor-toolbar-collaboration-status ml-2 inline-flex shrink-0 items-center"
:aria-label="collaborationStatusLabel"
>
<span
class="inline-flex size-5 items-center justify-center rounded-full border"
:class="collaborationStatusClasses"
>
<oc-icon :name="collaborationStatusIcon" fill-type="line" size-class="size-3" />
</span>
</div>
</div>
<div
v-if="canScrollLeft"
Expand All @@ -174,17 +187,20 @@ import {
watch
} from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { useGettext } from 'vue3-gettext'
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[]
teleport?: string
}>()

const textEditor = inject<TextEditorInstance>('textEditor')!
const { $gettext } = useGettext()

const scrollContainerRef = useTemplateRef('scrollContainer')
const canScrollLeft = ref(false)
Expand Down Expand Up @@ -285,6 +301,44 @@ const visible = computed(() => {
return !!unref(textEditor.editor)
})

const showCollaborationStatusIndicator = computed(() => {
const status = unref(textEditor.yjsStatus)
return status !== YjsStatus.Local && status !== null
})

const collaborationStatusLabel = computed(() => {
if (unref(textEditor.yjsStatus) === YjsStatus.Connected) {
return $gettext('Collaboration ready')
}
if (unref(textEditor.yjsStatus) === YjsStatus.Disconnected) {
return $gettext('Collaboration disconnected')
}
if (unref(textEditor.yjsStatus) === YjsStatus.Connecting) {
return $gettext('Collaboration connecting...')
}
return ''
})

const collaborationStatusIcon = computed(() => {
if (unref(textEditor.yjsStatus) === YjsStatus.Disconnected) {
return 'wifi-off'
}
return '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'
}
if (unref(textEditor.yjsStatus) === YjsStatus.Connecting) {
return 'border-gray-700/20 bg-gray-500/15 text-gray-700'
}
return ''
})

const isSourceMode = computed(() => unref(textEditor.state.sourceMode))
const sourceModeEnabledActionIds = ['source-mode', 'menu-zoom', 'zoom-in', 'zoom-out', 'zoom-reset']

Expand Down
2 changes: 2 additions & 0 deletions packages/web-pkg/src/editor/composables/useTextEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export function useTextEditor(options: TextEditorOptions): TextEditorInstance {

const contentType = ref(options.contentType)
const readonly = computed(() => toValue(options.readonly) ?? false)
const yjsStatus = computed(() => toValue(options.yjsStatus) ?? null)
const strategy = resolveStrategy(options.contentType, state)
const yjsFragment = options.ydocFragment ?? DEFAULT_YDOC_FRAGMENT

Expand Down Expand Up @@ -282,6 +283,7 @@ export function useTextEditor(options: TextEditorOptions): TextEditorInstance {
editor,
contentType,
readonly,
yjsStatus,
actionGroups: editorActionGroups,
getContent,
setContent,
Expand Down
5 changes: 5 additions & 0 deletions packages/web-pkg/src/editor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<YjsStatus | null>
}

export interface TextEditorLinkPanelRequest {
Expand All @@ -73,6 +76,8 @@ export interface TextEditorInstance {
contentType: Ref<ContentType>
/** Derived from the caller's `readonly` option; follows it while mounted. */
readonly: Ref<boolean>
/** Current transport status of the hosting Yjs session, if any. */
yjsStatus: Ref<YjsStatus | null>
actionGroups(): EditorActionGroup[]
getContent(): string
setContent(value: string): void
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<FileContext>({ space: mock<any>(), path: '/a.md' }))
// Deferred so the test controls when each half of the load completes.
let resolveInfo: (r: Resource) => void
Expand All @@ -107,7 +108,7 @@ function setup({
sessionOptions = options
return mock<YjsSession>({
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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading