mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 10:59:20 +02:00
430 lines
13 KiB
Vue
430 lines
13 KiB
Vue
<script lang="ts" setup>
|
|
import * as Y from "yjs"
|
|
import { HocuspocusProvider } from "@hocuspocus/provider"
|
|
import type { Editor } from "@tiptap/core"
|
|
import ContentEditor from "./ContentEditor.vue"
|
|
import NameEditor from "./NameEditor.vue"
|
|
import DiffEditor from "./diff/DiffEditor.vue"
|
|
import { redirectToLogin } from "~/plugins/03.api-fetch"
|
|
import { showToastMessage } from "~/components/toast"
|
|
import { refreshGapDecorationsInBackground } from "./drag-handle/gap-decorations"
|
|
import { editorCaretColors } from "~/assets/css"
|
|
import { DOCUMENT_HOOK_QUERY_KEYS } from "~/composables/api/useDocumentHookAPI"
|
|
import { asyncRenderBlockUids } from "./blocks/async-render"
|
|
|
|
interface BranchProviderEntry {
|
|
provider: HocuspocusProvider
|
|
ydoc: Y.Doc
|
|
synced: Ref<boolean>
|
|
}
|
|
|
|
// NOTE: this component is remounted every time the document ID
|
|
// is changed (page change)
|
|
const props = defineProps<{
|
|
allInitialSectionsLoaded: boolean
|
|
timestamps: DocumentTimestamps
|
|
}>()
|
|
const emit = defineEmits<{
|
|
(e: "updated-live-icon" | "updated-live-name", val: string): void
|
|
(e: "initial-load-complete" | "fade-in-complete"): void
|
|
(e: "branch-merged", deleted: boolean): void
|
|
(e: "open-settings", target: "github"): void
|
|
(e: "editor-ready" | "name-editor-ready", val: Editor): void
|
|
}>()
|
|
|
|
const config = useRuntimeConfig()
|
|
const documentHookAPI = useDocumentHookAPI()
|
|
const { t } = useI18n({ useScope: "global" })
|
|
const { fetchAuthSession } = useAuthSession()
|
|
const editorStore = useEditorStore()
|
|
const { useFetchDocumentBranchesByDocId } = useDocumentAPI()
|
|
const fetchBranches = useFetchDocumentBranchesByDocId(
|
|
() => editorStore.activeDocumentId,
|
|
)
|
|
const { isEditable, isCompactView } = useEditorMeta()
|
|
const route = useRoute()
|
|
const nuxtApp = useNuxtApp()
|
|
const fetchActiveBranchHooks = documentHookAPI.useFetchDocumentHooksByDocID(
|
|
() => editorStore.activeDocumentId,
|
|
() => editorStore.activeBranchId,
|
|
)
|
|
// the target's hooks load alongside the active ones, like its provider,
|
|
// so turning the diff on shows them without waiting on a request
|
|
const fetchTargetBranchHooks = documentHookAPI.useFetchDocumentHooksByDocID(
|
|
() => editorStore.activeDocumentId,
|
|
() => editorStore.targetBranchId,
|
|
)
|
|
const queryCache = useQueryCache()
|
|
const wsState = useWebSocketStateStore()
|
|
let unsubWsHooksChange: (() => void) | null | undefined = null
|
|
|
|
const userCaretDetails = computed(() => {
|
|
const caretColors = editorCaretColors()
|
|
const randomIndex = Math.floor(Math.random() * caretColors.length)
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- editorCaretColors always returns a non-empty list, so the random index is in bounds
|
|
const randomColor = caretColors[randomIndex]!
|
|
|
|
return {
|
|
name:
|
|
fetchAuthSession.state.value.data?.data?.user.name ||
|
|
t("editor.unknown-user-caret"),
|
|
color: randomColor.value,
|
|
}
|
|
})
|
|
|
|
const activeBranch = computed(() => {
|
|
const branches = fetchBranches.state.value.data
|
|
if (!branches || !editorStore.activeBranchId) {
|
|
return null // still loading branches
|
|
}
|
|
|
|
return branches.find((b) => b.branchId === editorStore.activeBranchId) ?? null
|
|
})
|
|
|
|
const branchProviders = shallowReactive(new Map<string, BranchProviderEntry>())
|
|
const activeBranchProvider = computed<BranchProviderEntry | null>(() => {
|
|
const bid = editorStore.activeBranchId
|
|
return bid ? (branchProviders.get(bid) ?? null) : null
|
|
})
|
|
const targetBranchProvider = computed<BranchProviderEntry | null>(() => {
|
|
const bid = editorStore.targetBranchId
|
|
return bid ? (branchProviders.get(bid) ?? null) : null
|
|
})
|
|
|
|
const nameEditor = ref<Editor | null>(null)
|
|
const contentEditor = ref<Editor | null>(null)
|
|
|
|
// the asynchronously rendered blocks the first editor holds; once every
|
|
// one of them has rendered the page height is settled and the fade-in
|
|
// cannot shift the content
|
|
const initialAsyncBlockUids = ref<string[] | null>(null)
|
|
const initialAsyncBlocksRendered = computed(
|
|
() =>
|
|
initialAsyncBlockUids.value?.every((uid) =>
|
|
editorStore.settledBlockRenders.has(uid),
|
|
) ?? false,
|
|
)
|
|
const readyToReveal = computed(
|
|
() => props.allInitialSectionsLoaded && initialAsyncBlocksRendered.value,
|
|
)
|
|
|
|
import.meta.hot?.accept(() => {
|
|
refreshGapDecorationsInBackground(contentEditor as Ref<Editor | null>)
|
|
})
|
|
|
|
// closing the tab never unmounts anything, so without this the provider is
|
|
// only noticed as gone when its socket drops — and until then everyone else
|
|
// still sees this user's caret. pagehide is the event that survives a tab
|
|
// close; a bfcache eviction (persisted) is skipped, since the page can be
|
|
// restored and would come back with dead providers.
|
|
useEventListener(window, "pagehide", (event: PageTransitionEvent) => {
|
|
if (event.persisted) {
|
|
return
|
|
}
|
|
|
|
destroyBranchProviders()
|
|
})
|
|
|
|
onBeforeUnmount(destroyBranchProviders)
|
|
|
|
onUnmounted(() => {
|
|
unsubWsHooksChange?.()
|
|
})
|
|
|
|
// the hook menus refetch after their own writes, but a hook the assistant or
|
|
// an MCP client adds arrives only on this topic: without it the indicators
|
|
// would sit stale until the list expired
|
|
watchImmediate(
|
|
() => editorStore.activeDocumentId,
|
|
(newId) => {
|
|
unsubWsHooksChange?.()
|
|
unsubWsHooksChange = null
|
|
|
|
if (!newId) {
|
|
return
|
|
}
|
|
|
|
unsubWsHooksChange = wsState.state?.subscribe(
|
|
makeWsHooksChangeTopic(newId),
|
|
(rawPayload) => {
|
|
const payload = rawPayload as WSHooksChangePayload
|
|
|
|
void queryCache.invalidateQueries({
|
|
key: DOCUMENT_HOOK_QUERY_KEYS.list(newId, payload.branchId),
|
|
})
|
|
},
|
|
)
|
|
},
|
|
)
|
|
|
|
watchImmediate(
|
|
[
|
|
() => editorStore.preloadedBranchIds,
|
|
() => editorStore.activeBranchId,
|
|
() => editorStore.targetBranchId,
|
|
],
|
|
(
|
|
[preloadedBranchIds, activeBranchId, targetBranchId],
|
|
[_1, oldActiveBranchId],
|
|
) => {
|
|
// bound to a local so the guard still narrows inside the callbacks
|
|
// below — a "null-<branchId>" name would open a bogus Y.Doc
|
|
const documentId = editorStore.activeDocumentId
|
|
if (!documentId) {
|
|
return
|
|
}
|
|
|
|
preloadedBranchIds.forEach((branchId) => {
|
|
if (!branchProviders.has(branchId)) {
|
|
const entry = createBranchProvider(
|
|
`${documentId}-${branchId}`,
|
|
activeBranchId === branchId && !oldActiveBranchId, // treat as initial load if this is the active branch
|
|
)
|
|
branchProviders.set(branchId, entry)
|
|
}
|
|
})
|
|
|
|
if (activeBranchId && !branchProviders.has(activeBranchId)) {
|
|
const entry = createBranchProvider(
|
|
`${documentId}-${activeBranchId}`,
|
|
oldActiveBranchId ? false : true, // only treat as initial load if there was no previous branch, otherwise it's a branch switch
|
|
)
|
|
branchProviders.set(activeBranchId, entry)
|
|
}
|
|
|
|
if (targetBranchId && !branchProviders.has(targetBranchId)) {
|
|
const entry = createBranchProvider(
|
|
`${documentId}-${targetBranchId}`,
|
|
false,
|
|
)
|
|
branchProviders.set(targetBranchId, entry)
|
|
}
|
|
|
|
// TODO: old branches need to be cleaned up, but doing that
|
|
// immediately causes issues when switching between branches quickly,
|
|
// as the provider is destroyed before the new one is ready. We
|
|
// should probably add some delay before destroying the old provider,
|
|
// and if the same branch becomes active again during that delay,
|
|
// we cancel the destruction.
|
|
/*
|
|
if (oldActiveBranchId && oldActiveBranchId !== activeBranchId) {
|
|
const entry = branchProviders.get(oldActiveBranchId)
|
|
if (entry) {
|
|
entry.provider.destroy()
|
|
branchProviders.delete(oldActiveBranchId)
|
|
}
|
|
}
|
|
|
|
if (oldTargetBranchId && oldTargetBranchId !== targetBranchId) {
|
|
const entry = branchProviders.get(oldTargetBranchId)
|
|
if (entry) {
|
|
entry.provider.destroy()
|
|
branchProviders.delete(oldTargetBranchId)
|
|
}
|
|
}
|
|
*/
|
|
},
|
|
)
|
|
|
|
watch(contentEditor, (v) => {
|
|
if (v) {
|
|
emit("editor-ready", v as Editor)
|
|
}
|
|
})
|
|
|
|
watchOnce(initialAsyncBlocksRendered, () => {
|
|
emit("initial-load-complete")
|
|
})
|
|
|
|
if (import.meta.dev) {
|
|
watchImmediate(
|
|
() =>
|
|
initialAsyncBlockUids.value?.filter((uid) =>
|
|
editorStore.settledBlockRenders.has(uid),
|
|
).length,
|
|
(rendered) => {
|
|
const total = initialAsyncBlockUids.value?.length ?? 0
|
|
|
|
if (rendered === undefined || total === 0) {
|
|
return
|
|
}
|
|
|
|
logAsyncBlockProgress(rendered, total)
|
|
},
|
|
)
|
|
|
|
watchOnce(initialAsyncBlocksRendered, () => {
|
|
if (initialAsyncBlockUids.value?.length) {
|
|
logSectionReady("async blocks")
|
|
}
|
|
})
|
|
|
|
watchOnce(readyToReveal, () => {
|
|
logSectionReady("all sections")
|
|
})
|
|
}
|
|
|
|
watch(nameEditor, (v) => {
|
|
if (v) {
|
|
emit("name-editor-ready", v as Editor)
|
|
}
|
|
})
|
|
|
|
// a branch that is still loading or unknown is treated as protected, so
|
|
// nothing is editable before the branch list has answered
|
|
watchImmediate(activeBranch, (branch) => {
|
|
editorStore.setActiveBranchProtected(branch ? branch.protected : true)
|
|
})
|
|
|
|
watchImmediate([isEditable, nameEditor, contentEditor], () => {
|
|
if (nameEditor.value) {
|
|
nameEditor.value.setEditable(isEditable.value)
|
|
}
|
|
|
|
if (contentEditor.value) {
|
|
contentEditor.value.setEditable(isEditable.value)
|
|
}
|
|
})
|
|
|
|
function destroyBranchProviders(): void {
|
|
for (const [, entry] of branchProviders) {
|
|
entry.provider.destroy()
|
|
}
|
|
|
|
branchProviders.clear()
|
|
}
|
|
|
|
function createBranchProvider(
|
|
streamName: string,
|
|
isInitial: boolean,
|
|
): BranchProviderEntry {
|
|
const ydoc = new Y.Doc()
|
|
const synced = ref(false)
|
|
const provider = new HocuspocusProvider({
|
|
url: config.public.authRealtimeAPIBaseWsURL,
|
|
name: streamName,
|
|
document: ydoc,
|
|
token: "notoken",
|
|
onAuthenticated() {
|
|
// TODO handle edit permissions properly?
|
|
},
|
|
onAuthenticationFailed() {
|
|
void redirectToLogin(route.fullPath, true, nuxtApp)
|
|
},
|
|
onStateless({ payload }) {
|
|
const data = JSON.parse(payload) as { type?: string }
|
|
if (data.type === "error") {
|
|
console.error(`Error in provider ${streamName}:`, data)
|
|
showToastMessage(
|
|
"error",
|
|
t("editor.realtime-error.title"),
|
|
t("editor.realtime-error.description"),
|
|
)
|
|
}
|
|
},
|
|
onConnect: () => {
|
|
if (isInitial) {
|
|
refreshGapDecorationsInBackground(contentEditor as Ref<Editor | null>)
|
|
}
|
|
},
|
|
onSynced: () => {
|
|
synced.value = true
|
|
if (isInitial) {
|
|
if (import.meta.dev) {
|
|
logSectionReady("document data")
|
|
}
|
|
|
|
refreshGapDecorationsInBackground(contentEditor as Ref<Editor | null>)
|
|
}
|
|
},
|
|
})
|
|
|
|
return { provider, ydoc, synced }
|
|
}
|
|
|
|
function updateEditor(editor: Editor) {
|
|
contentEditor.value = editor
|
|
|
|
initialAsyncBlockUids.value ??= asyncRenderBlockUids(editor.state.doc)
|
|
|
|
// force placeholder decorations to recompute
|
|
contentEditor.value.chain().run()
|
|
}
|
|
|
|
function handleDiffModeChange() {
|
|
// force placeholder decorations to recompute
|
|
contentEditor.value?.chain().run()
|
|
}
|
|
|
|
function showSettings(target: "github") {
|
|
emit("open-settings", target)
|
|
}
|
|
</script>
|
|
<template>
|
|
<div
|
|
v-if="editorStore.activeBranchId && activeBranchProvider?.synced.value"
|
|
class="flex h-full"
|
|
>
|
|
<!--
|
|
the editors remount when the active branch changes, to avoid issues
|
|
with them not properly updating when the Y.Doc/data provider changes.
|
|
They mount hidden but in layout, so asynchronously rendered blocks
|
|
settle the page height before the fade-in; opacity rather than
|
|
v-show, because gap decorations and charts measure themselves while
|
|
hidden. Pointer events are off until then, or the invisible content
|
|
would answer the pointer with carets and drag handles.
|
|
-->
|
|
<div
|
|
:key="editorStore.activeBranchId"
|
|
:data-compact-view="isCompactView ? '' : undefined"
|
|
:class="[
|
|
SCROLL_HIGHLIGHT_EDITOR_CLASS,
|
|
'relative flex h-full w-full flex-col items-center transition-opacity duration-300',
|
|
isCompactView && 'mx-auto max-w-320',
|
|
readyToReveal ? 'opacity-100' : 'pointer-events-none opacity-0',
|
|
]"
|
|
@transitionend.self="emit('fade-in-complete')"
|
|
>
|
|
<NameEditor
|
|
:active-branch-hooks="fetchActiveBranchHooks.state.value.data || []"
|
|
:target-branch-hooks="fetchTargetBranchHooks.state.value.data || []"
|
|
:timestamps="props.timestamps"
|
|
:active-branch-ydoc="activeBranchProvider.ydoc"
|
|
:active-branch-provider="activeBranchProvider.provider"
|
|
:target-branch-provider="targetBranchProvider?.provider"
|
|
:content-editor="contentEditor as Editor"
|
|
:user-caret-details="userCaretDetails"
|
|
@updated-live-icon="(v) => emit('updated-live-icon', v)"
|
|
@updated-live-name="(v) => emit('updated-live-name', v)"
|
|
@editor-ready="(v) => (nameEditor = v)"
|
|
@branch-merged="(deleted) => emit('branch-merged', deleted)"
|
|
@open-settings="(target: 'github') => showSettings(target)"
|
|
/>
|
|
<ContentEditor
|
|
v-show="!editorStore.reviewableDiffActive"
|
|
:active-branch-provider="activeBranchProvider.provider"
|
|
:active-branch-ydoc="activeBranchProvider.ydoc"
|
|
:active-branch-hooks="fetchActiveBranchHooks.state.value.data || []"
|
|
:name-editor="nameEditor as Editor"
|
|
:user-caret-details="userCaretDetails"
|
|
@editor-ready="(v) => updateEditor(v)"
|
|
@open-settings="(target: 'github') => showSettings(target)"
|
|
/>
|
|
<DiffEditor
|
|
v-if="
|
|
editorStore.reviewableDiffActive &&
|
|
targetBranchProvider &&
|
|
activeBranchProvider !== targetBranchProvider &&
|
|
contentEditor
|
|
"
|
|
:target-branch-ydoc="targetBranchProvider.ydoc"
|
|
:active-branch-ydoc="activeBranchProvider.ydoc"
|
|
:active-branch-hooks="fetchActiveBranchHooks.state.value.data || []"
|
|
:target-branch-hooks="fetchTargetBranchHooks.state.value.data || []"
|
|
:content-editor="contentEditor as Editor"
|
|
@diff-mode-changed="handleDiffModeChange"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|