diff --git a/app/package-lock.json b/app/package-lock.json index 008da57f..0d418cff 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -41,7 +41,6 @@ "morphdom": "^2.7.7", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-mosaic-component": "^6.1.1", "react-resizable-panels": "^3.0.6", "satori": "^0.18.3", "sonner": "^2.0.7", @@ -1685,6 +1684,7 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -4315,24 +4315,6 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, - "node_modules/@react-dnd/asap": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz", - "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==", - "license": "MIT" - }, - "node_modules/@react-dnd/invariant": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz", - "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==", - "license": "MIT" - }, - "node_modules/@react-dnd/shallowequal": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", - "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==", - "license": "MIT" - }, "node_modules/@resvg/resvg-js": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", @@ -8177,12 +8159,6 @@ "url": "https://polar.sh/cva" } }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" - }, "node_modules/clipboardy": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-4.0.0.tgz", @@ -9014,30 +8990,6 @@ "node": ">=0.3.1" } }, - "node_modules/dnd-core": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz", - "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==", - "license": "MIT", - "dependencies": { - "@react-dnd/asap": "^5.0.1", - "@react-dnd/invariant": "^4.0.1", - "redux": "^4.2.0" - } - }, - "node_modules/dnd-multi-backend": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/dnd-multi-backend/-/dnd-multi-backend-9.0.0.tgz", - "integrity": "sha512-BCUFes4x0LA2bZyEZFHeQzZ1CBZo6PB40zMOG/gNgICxjAZfN2jHgISowqkR1isdx/msUNzscxEb17SP7yc4KQ==", - "license": "MIT", - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/LouisBrunner" - }, - "peerDependencies": { - "dnd-core": "^16.0.1" - } - }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -9966,6 +9918,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-fifo": { @@ -10811,21 +10764,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "license": "BSD-3-Clause", - "dependencies": { - "react-is": "^16.7.0" - } - }, - "node_modules/hoist-non-react-statics/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/hookable": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", @@ -11017,12 +10955,6 @@ "license": "MIT", "optional": true }, - "node_modules/immutability-helper": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/immutability-helper/-/immutability-helper-3.1.1.tgz", - "integrity": "sha512-Q0QaXjPjwIju/28TsugCHNEASwoCcJSyJV3uO1sOIQGI0jKgm9f41Lvz0DZj3n46cNCyAZTsEYoY4C2bVRUzyQ==", - "license": "MIT" - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -12510,6 +12442,7 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, "license": "MIT" }, "node_modules/lodash.debounce": { @@ -12546,18 +12479,6 @@ "dev": true, "license": "MIT" }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -13256,15 +13177,6 @@ "dev": true, "license": "MIT" }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -13884,23 +13796,6 @@ "dev": true, "license": "MIT" }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -13994,21 +13889,6 @@ "destr": "^2.0.3" } }, - "node_modules/rdndmb-html5-to-touch": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/rdndmb-html5-to-touch/-/rdndmb-html5-to-touch-8.1.2.tgz", - "integrity": "sha512-efi3MaXYxWaLMd5xzF1bVvmX8erTMhYHSlaMjQe+tynf4IdtgRYfKLwYg+4Z5eq4k7idrjKHQOIMDE6D8LjnOA==", - "license": "MIT", - "dependencies": { - "dnd-multi-backend": "^8.1.2", - "react-dnd-html5-backend": "^16.0.1", - "react-dnd-touch-backend": "^16.0.1" - }, - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/LouisBrunner" - } - }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -14018,89 +13898,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-dnd": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", - "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==", - "license": "MIT", - "dependencies": { - "@react-dnd/invariant": "^4.0.1", - "@react-dnd/shallowequal": "^4.0.1", - "dnd-core": "^16.0.1", - "fast-deep-equal": "^3.1.3", - "hoist-non-react-statics": "^3.3.2" - }, - "peerDependencies": { - "@types/hoist-non-react-statics": ">= 3.3.1", - "@types/node": ">= 12", - "@types/react": ">= 16", - "react": ">= 16.14" - }, - "peerDependenciesMeta": { - "@types/hoist-non-react-statics": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-dnd-html5-backend": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz", - "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==", - "license": "MIT", - "dependencies": { - "dnd-core": "^16.0.1" - } - }, - "node_modules/react-dnd-multi-backend": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/react-dnd-multi-backend/-/react-dnd-multi-backend-9.0.0.tgz", - "integrity": "sha512-LAKDdyj4oMvVA/k2RiJ8KLIPO9sBiYIjIYtoFCuAgml9qQwIq+oTav2IXGfG4DrP49fBnVO7jjf5ofJMNOlWTA==", - "license": "MIT", - "dependencies": { - "dnd-multi-backend": "^9.0.0", - "react-dnd-preview": "^9.0.0" - }, - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/LouisBrunner" - }, - "peerDependencies": { - "dnd-core": "^16.0.1", - "react": "^16.14.0 || ^17.0.2 || ^18.0.0 || ^19.0.0", - "react-dnd": "^16.0.1", - "react-dom": "^16.14.0 || ^17.0.2 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/react-dnd-preview": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/react-dnd-preview/-/react-dnd-preview-9.0.0.tgz", - "integrity": "sha512-WZTbrrNDlCGYJGrITHN/obI2kpdaKV3AY6Il2LLZcA9ApzG5bbDXBlWSFwuw8eTCMjmCXs5Wcv+p2QCMxX1Afw==", - "license": "MIT", - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/LouisBrunner" - }, - "peerDependencies": { - "react": "^16.14.0 || ^17.0.2 || ^18.0.0 || ^19.0.0", - "react-dnd": "^16.0.1" - } - }, - "node_modules/react-dnd-touch-backend": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/react-dnd-touch-backend/-/react-dnd-touch-backend-16.0.1.tgz", - "integrity": "sha512-NonoCABzzjyWGZuDxSG77dbgMZ2Wad7eQiCd/ECtsR2/NBLTjGksPUx9UPezZ1nQ/L7iD130Tz3RUshL/ClKLA==", - "license": "MIT", - "dependencies": { - "@react-dnd/invariant": "^4.0.1", - "dnd-core": "^16.0.1" - } - }, "node_modules/react-dom": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", @@ -14120,30 +13917,6 @@ "dev": true, "license": "MIT" }, - "node_modules/react-mosaic-component": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/react-mosaic-component/-/react-mosaic-component-6.1.1.tgz", - "integrity": "sha512-Ivuj6AxRDlo/H8OiEDU1mdgivxuKbwGOa5Ub6Yf+bHcu0JWioT7ttlpCWF63/gKrJBlRMB6fW9/eNOXINg9+Gg==", - "license": "Apache-2.0", - "dependencies": { - "classnames": "^2.3.2", - "immutability-helper": "^3.1.1", - "lodash": "^4.17.21", - "prop-types": "^15.8.1", - "rdndmb-html5-to-touch": "^8.0.0", - "react-dnd": "^16.0.1", - "react-dnd-html5-backend": "^16.0.1", - "react-dnd-multi-backend": "^8.0.0", - "react-dnd-touch-backend": "^16.0.1", - "uuid": "^9.0.0" - }, - "funding": { - "url": "https://github.com/nomcopter/react-mosaic?sponsor=1" - }, - "peerDependencies": { - "react": ">=16" - } - }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -14353,15 +14126,6 @@ "node": ">=4" } }, - "node_modules/redux": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", - "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.9.2" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -16653,19 +16417,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/vite": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", diff --git a/app/package.json b/app/package.json index 755cbc19..cb6b042c 100644 --- a/app/package.json +++ b/app/package.json @@ -50,7 +50,6 @@ "morphdom": "^2.7.7", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-mosaic-component": "^6.1.1", "react-resizable-panels": "^3.0.6", "satori": "^0.18.3", "sonner": "^2.0.7", diff --git a/app/src/features/edit-document/model/view-context.tsx b/app/src/features/edit-document/model/view-context.tsx index 1e6e1107..ed42148b 100644 --- a/app/src/features/edit-document/model/view-context.tsx +++ b/app/src/features/edit-document/model/view-context.tsx @@ -11,6 +11,9 @@ type Ctx = { setViewMode: (mode: ViewModeSetter) => void viewModeHydrated: boolean hasPersistentViewMode: boolean + showBacklinks: boolean + setShowBacklinks: (show: boolean) => void + toggleBacklinks: () => void // Search request trigger for Header's SearchDialog searchPresetTag: string | null searchNonce: number @@ -23,6 +26,7 @@ export function ViewProvider({ children }: { children: React.ReactNode }) { const [viewMode, setViewModeState] = useState('editor') const [viewModeHydrated, setViewModeHydrated] = useState(() => typeof window === 'undefined') const [hasPersistentViewMode, setHasPersistentViewMode] = useState(false) + const [showBacklinks, setShowBacklinks] = useState(false) const [searchPresetTag, setSearchPresetTag] = useState(null) const [searchNonce, setSearchNonce] = useState(0) @@ -32,13 +36,9 @@ export function ViewProvider({ children }: { children: React.ReactNode }) { } try { const saved = localStorage.getItem(VIEW_MODE_STORAGE_KEY) - if (saved === 'editor' || saved === 'preview') { + if (saved === 'editor' || saved === 'split' || saved === 'preview') { setViewModeState(saved) setHasPersistentViewMode(true) - } else if (saved === 'split') { - setViewModeState('editor') - try { localStorage.setItem(VIEW_MODE_STORAGE_KEY, 'editor') } catch {} - setHasPersistentViewMode(true) } } catch { /* noop */ @@ -68,6 +68,10 @@ export function ViewProvider({ children }: { children: React.ReactNode }) { setSearchNonce((n) => n + 1) }, []) + const toggleBacklinks = useCallback(() => { + setShowBacklinks((prev) => !prev) + }, []) + useEffect(() => { if (typeof window === 'undefined') return const handler = (event: Event) => { @@ -84,10 +88,23 @@ export function ViewProvider({ children }: { children: React.ReactNode }) { viewModeHydrated, hasPersistentViewMode, setViewMode, + showBacklinks, + setShowBacklinks, + toggleBacklinks, + searchPresetTag, + searchNonce, + openSearch, + }), [ + viewMode, + viewModeHydrated, + hasPersistentViewMode, + showBacklinks, + toggleBacklinks, searchPresetTag, searchNonce, openSearch, - }), [viewMode, viewModeHydrated, hasPersistentViewMode, searchPresetTag, searchNonce, openSearch, setViewMode]) + setViewMode, + ]) return {children} } diff --git a/app/src/features/edit-document/public/useViewController.ts b/app/src/features/edit-document/public/useViewController.ts index 194b2e75..f42c95aa 100644 --- a/app/src/features/edit-document/public/useViewController.ts +++ b/app/src/features/edit-document/public/useViewController.ts @@ -9,6 +9,9 @@ export function useViewController() { return useMemo(() => ({ viewMode: ctx.viewMode as ViewMode, setViewMode: (mode: ViewMode) => ctx.setViewMode(mode), + showBacklinks: ctx.showBacklinks, + setShowBacklinks: ctx.setShowBacklinks, + toggleBacklinks: ctx.toggleBacklinks, openSearch: (presetTag?: string) => ctx.openSearch(presetTag), searchPresetTag: ctx.searchPresetTag, searchNonce: ctx.searchNonce, diff --git a/app/src/features/edit-document/ui/Editor.tsx b/app/src/features/edit-document/ui/Editor.tsx index 5596c7a5..9eef6930 100644 --- a/app/src/features/edit-document/ui/Editor.tsx +++ b/app/src/features/edit-document/ui/Editor.tsx @@ -10,7 +10,6 @@ import { useShareToken } from '@/shared/contexts/share-token-context' import { useTheme } from '@/shared/contexts/theme-context' import { useIsMobile } from '@/shared/hooks/use-mobile' import { useShortcut } from '@/shared/hooks/use-shortcut' -import { MOSAIC_SCROLL_SYNC_EVENT, type MosaicScrollSyncDetail, dispatchMosaicScrollSync } from '@/shared/lib/mosaic-events' import type { ViewMode } from '@/shared/types/view-mode' import { listDocuments } from '@/entities/document' @@ -61,14 +60,12 @@ export type MarkdownEditorProps = { initialView?: ViewMode forcedView?: ViewMode embedded?: boolean - scrollSyncGroupId?: string | null userName?: string userId?: string documentId: string documentTitle?: string | null documentType?: string | null documentEditorPluginsEnabled?: boolean - documentEditorPanePlacement?: 'extraRight' | 'mosaic' onDocumentEditorPaneHostChange?: (host: DocumentEditorPaneHostState | null) => void readOnly?: boolean extraRight?: React.ReactNode @@ -102,17 +99,15 @@ export function MarkdownEditor(props: MarkdownEditorProps) { const { doc, awareness, - initialView: initialViewProp = 'editor', + initialView: initialViewProp = 'split', forcedView, embedded = false, - scrollSyncGroupId = null, userId, userName, documentId, documentTitle, documentType, documentEditorPluginsEnabled = true, - documentEditorPanePlacement = 'extraRight', onDocumentEditorPaneHostChange, readOnly = false, extraRight, @@ -180,13 +175,6 @@ export function MarkdownEditor(props: MarkdownEditorProps) { language: 'markdown', onTextChange: () => {}, }) - const mosaicGroupIdRef = useRef(scrollSyncGroupId) - useEffect(() => { - mosaicGroupIdRef.current = scrollSyncGroupId - }, [scrollSyncGroupId]) - const mosaicScrollRafRef = useRef(null) - const suppressMosaicEmitRef = useRef(false) - const suppressMosaicTimeoutRef = useRef(null) const unregisterEditorRef = useRef void)>(null) const focusDisposableRef = useRef void }>(null) const blurDisposableRef = useRef void }>(null) @@ -428,34 +416,6 @@ export function MarkdownEditor(props: MarkdownEditorProps) { }) ;(editor as any).__disposeScroll = () => safeExecute('dispose scroll listener', () => scrollDispose?.dispose?.()) - // Mosaic scroll sync: emit current top line to paired preview tile (by group) - try { - const mosaicScrollDispose = editor.onDidScrollChange?.(() => { - const groupId = mosaicGroupIdRef.current - if (!groupId) return - if (!syncScrollRef.current) return - if (suppressMosaicEmitRef.current) return - if (mosaicScrollRafRef.current != null) return - mosaicScrollRafRef.current = window.requestAnimationFrame(() => { - mosaicScrollRafRef.current = null - try { - if ((editor as any)?._isDisposed === true) return - const domNode = editor.getDomNode?.() - if (!domNode) return - const range = editor.getVisibleRanges?.()?.[0] - const line = range?.startLineNumber ?? editor.getPosition?.()?.lineNumber ?? 1 - if (!Number.isFinite(line) || line < 1) return - dispatchMosaicScrollSync({ groupId, source: 'editor', line }) - } catch (error) { - logEditorError('mosaic scroll sync emit', error) - } - }) - }) - ;(editor as any).__disposeMosaicScroll = () => safeExecute('dispose mosaic scroll listener', () => mosaicScrollDispose?.dispose?.()) - } catch (error) { - logEditorError('register mosaic scroll listener', error) - } - // Handle paste (Ctrl+V) with files from clipboard const dom = editor.getDomNode() as HTMLElement | null const pasteHandler = async (event: ClipboardEvent) => { @@ -524,7 +484,6 @@ export function MarkdownEditor(props: MarkdownEditorProps) { }) safeExecute('dispose change listener', () => (anyEditor as any)?.__disposeChange?.()) safeExecute('dispose scroll listener', () => (anyEditor as any)?.__disposeScroll?.()) - safeExecute('dispose mosaic scroll listener', () => (anyEditor as any)?.__disposeMosaicScroll?.()) safeExecute('dispose paste handler', () => (anyEditor as any)?.__disposePaste?.()) safeExecute('dispose wiki handler', () => (anyEditor as any)?.__disposeWiki?.()) safeExecute('dispose cursor handler', () => (anyEditor as any)?.__disposeCursor?.()) @@ -572,74 +531,9 @@ export function MarkdownEditor(props: MarkdownEditorProps) { blurDisposableRef.current = null safeExecute('unregister editor instance', () => unregisterEditorRef.current?.()) unregisterEditorRef.current = null - safeExecute('cancel mosaic scroll raf', () => { - if (mosaicScrollRafRef.current != null) { - window.cancelAnimationFrame(mosaicScrollRafRef.current) - mosaicScrollRafRef.current = null - } - }) - safeExecute('cancel mosaic suppress timeout', () => { - if (suppressMosaicTimeoutRef.current != null) { - window.clearTimeout(suppressMosaicTimeoutRef.current) - suppressMosaicTimeoutRef.current = null - } - suppressMosaicEmitRef.current = false - }) disableVimMode() }, [editorRef, setEditor, disableVimMode]) - useEffect(() => { - if (typeof window === 'undefined') return - if (!scrollSyncGroupId) return - const handler = (event: Event) => { - try { - if (!syncScrollRef.current) return - const detail = (event as CustomEvent).detail - if (!detail || detail.source !== 'preview') return - if (detail.groupId !== scrollSyncGroupId) return - const line = detail.line - if (!Number.isFinite(line) || (line as number) < 1) return - - const editorInstance = editorRef.current as monacoNs.editor.IStandaloneCodeEditor | null - if (!editorInstance) return - if ((editorInstance as any)?._isDisposed === true) return - const domNode = editorInstance.getDomNode?.() - if (!domNode) return - - const model = editorInstance.getModel?.() - if (!model) return - const maxLine = model.getLineCount?.() ?? null - const clamped = maxLine - ? Math.min(maxLine, Math.max(1, Math.floor(line as number))) - : Math.max(1, Math.floor(line as number)) - - if (suppressMosaicTimeoutRef.current != null) { - window.clearTimeout(suppressMosaicTimeoutRef.current) - suppressMosaicTimeoutRef.current = null - } - suppressMosaicEmitRef.current = true - try { - ;(editorInstance as any).revealLineNearTop?.(clamped) - } catch (error) { - // Avoid noisy errors when editor is being disposed during tile close/layout changes. - if (error instanceof Error && /InstantiationService has been disposed/i.test(error.message)) return - throw error - } finally { - suppressMosaicTimeoutRef.current = window.setTimeout(() => { - suppressMosaicTimeoutRef.current = null - suppressMosaicEmitRef.current = false - }, 120) - } - } catch (error) { - logEditorError('mosaic scroll sync receive', error) - } - } - window.addEventListener(MOSAIC_SCROLL_SYNC_EVENT, handler as EventListener) - return () => { - window.removeEventListener(MOSAIC_SCROLL_SYNC_EVENT, handler as EventListener) - } - }, [editorRef, scrollSyncGroupId]) - const toggleVim = useCallback(async () => { const next = !isVimMode setIsVimMode(next) @@ -670,13 +564,12 @@ export function MarkdownEditor(props: MarkdownEditorProps) { viewMode={view as ViewMode} syncScroll={syncScroll} onSyncScrollToggle={() => setSyncScroll((s) => !s)} - syncScrollAvailable={Boolean(scrollSyncGroupId)} isVimMode={isVimMode} onVimModeToggle={toggleVim} onFileUpload={readOnly ? undefined : handleFileUpload} readOnly={readOnly} /> - ), [handleToolbarCommand, view, syncScroll, scrollSyncGroupId, isVimMode, toggleVim, handleFileUpload, readOnly]) + ), [handleToolbarCommand, view, syncScroll, isVimMode, toggleVim, handleFileUpload, readOnly]) const shortcutToggleSync = useCallback(() => { if (!isThisEditorActive()) return @@ -1049,7 +942,7 @@ export function MarkdownEditor(props: MarkdownEditorProps) { onPaneHostChange: onDocumentEditorPaneHostChange, }) - const resolvedExtraRight = extraRight ?? (documentEditorPanePlacement === 'extraRight' ? pluginPanes.extraRight : undefined) + const resolvedExtraRight = extraRight ?? pluginPanes.extraRight diff --git a/app/src/features/edit-document/ui/PreviewPane.tsx b/app/src/features/edit-document/ui/PreviewPane.tsx index c7cffa56..6480b403 100644 --- a/app/src/features/edit-document/ui/PreviewPane.tsx +++ b/app/src/features/edit-document/ui/PreviewPane.tsx @@ -17,6 +17,7 @@ import { useViewController } from '../public/useViewController' export type PreviewPaneProps = { content: string viewMode?: ViewMode + isSecondaryViewer?: boolean onScroll?: (scrollTop: number, scrollPercentage: number) => void onScrollAnchorLine?: (line: number) => void scrollPercentage?: number @@ -30,7 +31,7 @@ export type PreviewPaneProps = { taskToggleDisabled?: boolean } -function PreviewPaneComponent({ content, viewMode = 'preview', onScroll, onScrollAnchorLine, scrollPercentage, documentIdOverride, onNavigate, forceFloatingToc = false, stickToBottom = false, scrollToLine, onToggleTask, taskToggleDisabled }: PreviewPaneProps) { +function PreviewPaneComponent({ content, viewMode = 'preview', isSecondaryViewer = false, onScroll, onScrollAnchorLine, scrollPercentage, documentIdOverride, onNavigate, forceFloatingToc = false, stickToBottom = false, scrollToLine, onToggleTask, taskToggleDisabled }: PreviewPaneProps) { const vc = useViewController() const onTagClickStable = React.useCallback((tag: string) => { vc.openSearch(tag) @@ -112,10 +113,11 @@ function PreviewPaneComponent({ content, viewMode = 'preview', onScroll, onScrol cn( 'prose prose-neutral dark:prose-invert break-words overflow-wrap-anywhere', viewMode === 'preview' ? 'max-w-6xl mx-auto' : 'max-w-none', - ), [viewMode]) + isSecondaryViewer && 'markdown-preview-secondary', + ), [viewMode, isSecondaryViewer]) - const showAsideToc = viewMode === 'preview' && !isMobile && !forceFloatingToc - const showFloatingTrigger = viewMode === 'split' || (viewMode === 'preview' && isMobile) || forceFloatingToc + const showAsideToc = viewMode === 'preview' && !isMobile && !isSecondaryViewer && !forceFloatingToc + const showFloatingTrigger = viewMode === 'split' || (viewMode === 'preview' && isMobile) || isSecondaryViewer || forceFloatingToc // Apply external scroll percentage to container (fallback when no anchor line) useEffect(() => { @@ -257,6 +259,7 @@ function PreviewPaneComponent({ content, viewMode = 'preview', onScroll, onScrol @@ -268,7 +271,7 @@ function PreviewPaneComponent({ content, viewMode = 'preview', onScroll, onScrol onClick={() => setShowFloatingToc((s) => !s)} className={cn( 'p-3 rounded-full border border-primary/60 bg-primary text-primary-foreground shadow-lg transition-all hover:bg-primary/90 hover:shadow-xl z-40', - isMobile ? 'fixed bottom-6 right-6' : 'absolute bottom-6 right-6' + (isMobile || forceFloatingToc) ? 'fixed bottom-6 right-6' : 'absolute bottom-6 right-6' )} title="Table of Contents" size="icon" @@ -282,7 +285,7 @@ function PreviewPaneComponent({ content, viewMode = 'preview', onScroll, onScrol ref={floatingTocRef} className={cn( overlayPanelClass, - isMobile + (isMobile || forceFloatingToc) ? 'fixed bottom-24 right-6 w-[min(320px,calc(100%-2.5rem))] z-40' : 'absolute bottom-20 right-6 w-[300px] max-w-[calc(100%-3rem)] z-40', )} @@ -300,6 +303,7 @@ function PreviewPaneComponent({ content, viewMode = 'preview', onScroll, onScrol
) : undefined} onItemClick={handleFloatingItemClick} floating diff --git a/app/src/features/file-tree/ui/FileNode.tsx b/app/src/features/file-tree/ui/FileNode.tsx index e5001752..b8e82c67 100644 --- a/app/src/features/file-tree/ui/FileNode.tsx +++ b/app/src/features/file-tree/ui/FileNode.tsx @@ -31,7 +31,6 @@ import { toast } from 'sonner' import type { GitPullConflictItem } from '@/shared/api' import useInView from '@/shared/hooks/use-in-view' -import { dispatchOpenPreviewTile } from '@/shared/lib/mosaic-events' import { overlayMenuClass } from '@/shared/lib/overlay-classes' import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/ui/button' @@ -69,6 +68,7 @@ type FileNodeProps = { onDrop: (e: React.DragEvent, id: string, type: 'file' | 'folder', parentId?: string) => void onDragOver: (e: React.DragEvent, nodeId?: string, nodeType?: 'file' | 'folder') => void pluginRules?: FileTreeRule[] + onOpenSecondaryViewer?: (id: string, type?: 'document' | 'scrap') => void gitEnabled?: boolean conflict?: GitPullConflictItem | null } @@ -92,6 +92,7 @@ export const FileNode = memo(function FileNode({ onDrop, onDragOver, pluginRules, + onOpenSecondaryViewer, gitEnabled = false, conflict = null, }: FileNodeProps) { @@ -212,11 +213,11 @@ export const FileNode = memo(function FileNode({ if (event && (event.metaKey || event.ctrlKey)) { event.preventDefault() event.stopPropagation() - dispatchOpenPreviewTile(node.id) + onOpenSecondaryViewer?.(node.sourceId ?? node.id, 'document') return } onSelect(node) - }, [node, onSelect]) + }, [node, onOpenSecondaryViewer, onSelect]) const handleOpenConflictResolver = useCallback(() => { router.navigate({ to: '/document/$id', @@ -601,9 +602,9 @@ export const FileNode = memo(function FileNode({ )} guardMenuAction(event, () => dispatchOpenPreviewTile(node.id))} + onSelect={(event) => guardMenuAction(event, () => onOpenSecondaryViewer?.(node.sourceId ?? node.id, 'document'))} > - Open in Tile + Open in Side Pane {hasConflict && !isShareMount && ( { if (segments.length <= 1) return segments const first = segments[0] ?? '' @@ -53,6 +54,7 @@ type DocumentHit = Pick export default function SearchDialog({ open, onOpenChange, presetTag }: Props) { const navigate = useNavigate() + const { openSecondaryViewer } = useSecondaryViewer() const isMobile = useIsMobile() const [query, setQuery] = React.useState('') const [docs, setDocs] = React.useState([]) @@ -94,12 +96,12 @@ export default function SearchDialog({ open, onOpenChange, presetTag }: Props) { (event) => { if (!open) return if (!activeDocId) return - dispatchOpenPreviewTile(activeDocId) + openSecondaryViewer(activeDocId) onOpenChange(false) event.preventDefault() event.stopPropagation() }, - [activeDocId, onOpenChange, open], + [activeDocId, onOpenChange, open, openSecondaryViewer], ), { preventDefault: false }, ) @@ -427,7 +429,7 @@ export default function SearchDialog({ open, onOpenChange, presetTag }: Props) {

Quick search

- Use ↑/↓ to move, Enter to open, Shift+Enter to open in a tile, #tag for tags, and folder/ to scope by path. + Use ↑/↓ to move, Enter to open, Shift+Enter to open in the side pane, #tag for tags, and folder/ to scope by path.

diff --git a/app/src/features/secondary-viewer/index.ts b/app/src/features/secondary-viewer/index.ts new file mode 100644 index 00000000..3667bedd --- /dev/null +++ b/app/src/features/secondary-viewer/index.ts @@ -0,0 +1,8 @@ +export { + SecondaryViewerProvider, + useSecondaryViewer, +} from './model/secondary-viewer-context' +export { + useSecondaryViewerContent, + type SecondaryViewerItemType, +} from './model/useSecondaryViewerContent' diff --git a/app/src/features/secondary-viewer/model/secondary-viewer-context.tsx b/app/src/features/secondary-viewer/model/secondary-viewer-context.tsx new file mode 100644 index 00000000..b272f74b --- /dev/null +++ b/app/src/features/secondary-viewer/model/secondary-viewer-context.tsx @@ -0,0 +1,95 @@ +"use client" + +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' + +type SecondaryType = 'document' | 'scrap' | 'plugin' + +type StoredState = { + documentId: string | null + documentType: SecondaryType + isOpen: boolean +} + +type CtxType = { + secondaryDocumentId: string | null + secondaryDocumentType: SecondaryType + showSecondaryViewer: boolean + setSecondaryDocumentId: (id: string | null) => void + setSecondaryDocumentType: (t: SecondaryType) => void + setShowSecondaryViewer: (v: boolean) => void + openSecondaryViewer: (id: string, type?: SecondaryType) => void + closeSecondaryViewer: () => void +} + +const STORAGE_KEY = 'refmd-secondary-viewer' +const Ctx = createContext(null) + +export function SecondaryViewerProvider({ children }: { children: React.ReactNode }) { + const [secondaryDocumentId, setSecondaryDocumentIdState] = useState(null) + const [secondaryDocumentType, setSecondaryDocumentTypeState] = useState('document') + const [showSecondaryViewer, setShowSecondaryViewerState] = useState(false) + const [initialized, setInitialized] = useState(false) + + useEffect(() => { + try { + const raw = typeof window !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null + if (raw) { + const parsed = JSON.parse(raw) as StoredState + setSecondaryDocumentIdState(parsed.documentId) + setSecondaryDocumentTypeState(parsed.documentType || 'document') + setShowSecondaryViewerState(!!parsed.isOpen) + } + } catch {} + setInitialized(true) + }, []) + + useEffect(() => { + if (!initialized) return + try { + const state: StoredState = { + documentId: secondaryDocumentId, + documentType: secondaryDocumentType, + isOpen: showSecondaryViewer, + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)) + } catch {} + }, [initialized, secondaryDocumentId, secondaryDocumentType, showSecondaryViewer]) + + const setSecondaryDocumentId = useCallback((id: string | null) => setSecondaryDocumentIdState(id), []) + const setSecondaryDocumentType = useCallback((t: SecondaryType) => setSecondaryDocumentTypeState(t), []) + const setShowSecondaryViewer = useCallback((v: boolean) => setShowSecondaryViewerState(v), []) + const openSecondaryViewer = useCallback((id: string, type: SecondaryType = 'document') => { + setSecondaryDocumentIdState(id) + setSecondaryDocumentTypeState(type) + setShowSecondaryViewerState(true) + }, []) + const closeSecondaryViewer = useCallback(() => setShowSecondaryViewerState(false), []) + + const value = useMemo(() => ({ + secondaryDocumentId, + secondaryDocumentType, + showSecondaryViewer, + setSecondaryDocumentId, + setSecondaryDocumentType, + setShowSecondaryViewer, + openSecondaryViewer, + closeSecondaryViewer, + }), [ + secondaryDocumentId, + secondaryDocumentType, + showSecondaryViewer, + setSecondaryDocumentId, + setSecondaryDocumentType, + setShowSecondaryViewer, + openSecondaryViewer, + closeSecondaryViewer, + ]) + + return {children} +} + +export function useSecondaryViewer() { + const value = useContext(Ctx) + if (!value) throw new Error('useSecondaryViewer must be used within SecondaryViewerProvider') + return value +} diff --git a/app/src/features/secondary-viewer/model/useSecondaryViewerContent.ts b/app/src/features/secondary-viewer/model/useSecondaryViewerContent.ts new file mode 100644 index 00000000..7f4f7c67 --- /dev/null +++ b/app/src/features/secondary-viewer/model/useSecondaryViewerContent.ts @@ -0,0 +1,168 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { createYjsConnection, destroyYjsConnection } from '@/shared/lib/yjsConnection' +import type { YjsConnection } from '@/shared/lib/yjsConnection' + +import { fetchDocumentMeta } from '@/entities/document' + +import { + resolvePluginForDocument, + type DocumentPluginMatch, +} from '@/features/plugins/lib/resolution' + +export type SecondaryViewerItemType = 'document' | 'scrap' | 'plugin' + +type YTextBinding = { + ytext: any + observer: (() => void) | null +} + +export function useSecondaryViewerContent(documentId: string | null, documentType: SecondaryViewerItemType) { + const [content, setContent] = useState('') + const [error, setError] = useState(null) + const [currentType, setCurrentType] = useState(documentType) + const [isInitialLoading, setIsInitialLoading] = useState(false) + const [pluginMatch, setPluginMatch] = useState(null) + + const connectionRef = useRef(null) + const ytextRef = useRef(null) + + const cleanupConnection = useCallback(() => { + const binding = ytextRef.current + if (binding?.observer) { + try { + binding.ytext.unobserve(binding.observer) + } catch { + /* noop */ + } + } + ytextRef.current = null + if (connectionRef.current) { + destroyYjsConnection(connectionRef.current) + connectionRef.current = null + } + }, []) + + useEffect(() => { + let disposed = false + let statusHandler: ((e: any) => void) | null = null + + const shareToken = (() => { + try { + return new URLSearchParams(window.location.search).get('token') || null + } catch { + return null + } + })() + + setPluginMatch(null) + setError(null) + setContent('') + setCurrentType(documentType) + setIsInitialLoading(true) + cleanupConnection() + + if (!documentId) { + setIsInitialLoading(false) + return () => {} + } + + ;(async () => { + try { + const meta = await fetchDocumentMeta(documentId, shareToken ?? undefined).catch(() => null) + if (disposed) return + + const createdByPlugin = + (meta as any)?.created_by_plugin ?? (meta as any)?.createdByPlugin ?? null + if (createdByPlugin) { + const plugin = await resolvePluginForDocument(documentId, shareToken, { + source: 'secondary', + }) + if (disposed) return + if (plugin) { + setPluginMatch(plugin) + setCurrentType('plugin') + setIsInitialLoading(false) + return + } + } else { + const plugin = await resolvePluginForDocument(documentId, shareToken, { + source: 'secondary', + }) + if (disposed) return + if (plugin) { + setPluginMatch(plugin) + setCurrentType('plugin') + setIsInitialLoading(false) + return + } + } + + if (documentType === 'scrap') { + setCurrentType('scrap') + setContent('# Scrap preview is not supported yet.') + setIsInitialLoading(false) + return + } + + const connection = await createYjsConnection(documentId, { token: shareToken ?? undefined }) + if (disposed) { + destroyYjsConnection(connection) + return + } + + connectionRef.current = connection + const { doc, provider } = connection + const ytext = doc.getText('content') + + const apply = () => setContent(String(ytext.toString() || '')) + apply() + + const observer = () => apply() + ytext.observe(observer) + ytextRef.current = { ytext, observer } + + statusHandler = (e: any) => { + if (e?.status === 'connected') apply() + } + try { + provider.on?.('status', statusHandler) + } catch { + statusHandler = null + } + + setCurrentType('document') + } catch (err: any) { + if (!disposed) { + console.error('[plugins] secondary viewer content load failed', documentId, err) + setError(err?.message || 'Failed to load content') + } + } finally { + if (!disposed) { + setIsInitialLoading(false) + } + } + })() + + return () => { + disposed = true + if (statusHandler && connectionRef.current) { + try { + connectionRef.current.provider.off?.('status', statusHandler) + } catch { + /* noop */ + } + } + cleanupConnection() + } + }, [cleanupConnection, documentId, documentType]) + + return { + content, + error, + currentType, + isInitialLoading, + pluginMatch, + setError, + } +} diff --git a/app/src/routes/(app)/document/$id.tsx b/app/src/routes/(app)/document/$id.tsx index 9bf95b61..c70397fd 100644 --- a/app/src/routes/(app)/document/$id.tsx +++ b/app/src/routes/(app)/document/$id.tsx @@ -1,13 +1,10 @@ import { createFileRoute, useParams } from '@tanstack/react-router' -import { useIsMobile } from '@/shared/hooks/use-mobile' - import { fetchDocumentMeta } from '@/entities/document' import { buildCanonicalUrl, buildOgImageUrl } from '@/entities/public' import { documentBeforeLoadGuard } from '@/features/auth' -import DocumentMosaicWorkspace from '@/widgets/document/DocumentMosaicWorkspace' import DocumentPage, { type DocumentLoaderData } from '@/widgets/document/DocumentPage' import RouteError from '@/widgets/routes/RouteError' import RoutePending from '@/widgets/routes/RoutePending' @@ -102,36 +99,13 @@ function DocumentRouteComponent() { const { id } = useParams({ from: '/(app)/document/$id' }) const loaderData = Route.useLoaderData() as LoaderData | undefined const search = Route.useSearch() as DocumentRouteSearch - const isMobile = useIsMobile() const shareToken = loaderData?.token ?? (typeof search.token === 'string' && search.token.trim().length > 0 ? search.token.trim() : undefined) - const shareScope = search.shareScope === 'folder' || search.shareScope === 'document' ? search.shareScope : undefined - const isShareMount = (() => { - const raw = search.shareMount ?? search.share_mount - if (raw == null) return false - if (typeof raw === 'string') { - const normalized = raw.trim().toLowerCase() - return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on' - } - return Boolean(raw) - })() const conflictMode = Object.prototype.hasOwnProperty.call(search, 'conflict') - if (isMobile) { - return ( - - ) - } return ( - ) diff --git a/app/src/routes/__root.tsx b/app/src/routes/__root.tsx index d1c26d78..325c884e 100644 --- a/app/src/routes/__root.tsx +++ b/app/src/routes/__root.tsx @@ -19,6 +19,7 @@ import { Toaster } from '@/shared/ui/sonner' import { AuthProvider, useAuthContext } from '@/features/auth' import { EditorProvider, ViewProvider } from '@/features/edit-document' +import { SecondaryViewerProvider } from '@/features/secondary-viewer' import { ShortcutRegistryProvider } from '@/features/shortcuts' import { Header } from '@/widgets/header/Header' @@ -122,10 +123,12 @@ function RootComponent() { - - - - + + + + + + diff --git a/app/src/shared/config/shortcuts.ts b/app/src/shared/config/shortcuts.ts index 20a2581d..5e8992b1 100644 --- a/app/src/shared/config/shortcuts.ts +++ b/app/src/shared/config/shortcuts.ts @@ -74,8 +74,8 @@ export const SHORTCUT_ACTIONS: ShortcutAction[] = [ }, { id: 'file-tree.open.tile', - label: 'Open in tile', - description: 'Open the current document selection as a tile', + label: 'Open in side pane', + description: 'Open the current document selection in the side pane', category: 'Navigation', scope: 'global', allowInInputs: true, @@ -144,8 +144,8 @@ export const SHORTCUT_ACTIONS: ShortcutAction[] = [ }, { id: 'view.mode.split', - label: 'Editor + preview tiles', - description: 'Open editor and preview tiles for the current document', + label: 'Editor + preview', + description: 'Show editor and preview side by side', category: 'View', scope: 'view', default: { @@ -166,8 +166,8 @@ export const SHORTCUT_ACTIONS: ShortcutAction[] = [ }, { id: 'view.backlinks.toggle', - label: 'Open backlinks tile', - description: 'Open backlinks in a tile', + label: 'Toggle backlinks', + description: 'Open or close backlinks in the side pane', category: 'View', scope: 'view', default: { @@ -175,204 +175,6 @@ export const SHORTCUT_ACTIONS: ShortcutAction[] = [ windows: [chord('b', { ctrl: true, alt: true })], }, }, - { - id: 'tiles.split.direction.auto', - label: 'Tile insertion split: Auto', - description: 'When opening a document in a new tile, choose split direction automatically', - category: 'View', - scope: 'view', - default: { - mac: [chord('a', { meta: true, alt: true, shift: true })], - windows: [chord('a', { ctrl: true, alt: true, shift: true })], - }, - }, - { - id: 'tiles.split.direction.row', - label: 'Tile insertion split: Left/Right', - description: 'When opening a document in a new tile, split left/right only', - category: 'View', - scope: 'view', - default: { - mac: [chord('r', { meta: true, alt: true, shift: true })], - windows: [chord('r', { ctrl: true, alt: true, shift: true })], - }, - }, - { - id: 'tiles.split.direction.column', - label: 'Tile insertion split: Top/Bottom', - description: 'When opening a document in a new tile, split top/bottom only', - category: 'View', - scope: 'view', - default: { - mac: [chord('c', { meta: true, alt: true, shift: true })], - windows: [chord('c', { ctrl: true, alt: true, shift: true })], - }, - }, - { - id: 'tiles.swap.left', - label: 'Swap tile left', - description: 'Swap the active tile contents with the tile to the left', - category: 'View', - scope: 'view', - default: { - mac: [chord('ArrowLeft', { meta: true, alt: true, shift: true })], - windows: [chord('ArrowLeft', { ctrl: true, alt: true, shift: true })], - }, - }, - { - id: 'tiles.swap.right', - label: 'Swap tile right', - description: 'Swap the active tile contents with the tile to the right', - category: 'View', - scope: 'view', - default: { - mac: [chord('ArrowRight', { meta: true, alt: true, shift: true })], - windows: [chord('ArrowRight', { ctrl: true, alt: true, shift: true })], - }, - }, - { - id: 'tiles.swap.up', - label: 'Swap tile up', - description: 'Swap the active tile contents with the tile above', - category: 'View', - scope: 'view', - default: { - mac: [chord('ArrowUp', { meta: true, alt: true, shift: true })], - windows: [chord('ArrowUp', { ctrl: true, alt: true, shift: true })], - }, - }, - { - id: 'tiles.swap.down', - label: 'Swap tile down', - description: 'Swap the active tile contents with the tile below', - category: 'View', - scope: 'view', - default: { - mac: [chord('ArrowDown', { meta: true, alt: true, shift: true })], - windows: [chord('ArrowDown', { ctrl: true, alt: true, shift: true })], - }, - }, - { - id: 'tiles.swap.last', - label: 'Swap with last active tile', - description: 'Swap the active tile contents with the previously active tile', - category: 'View', - scope: 'view', - default: { - mac: [chord('w', { meta: true, alt: true, shift: true })], - windows: [chord('w', { ctrl: true, alt: true, shift: true })], - }, - }, - { - id: 'tiles.focus.left', - label: 'Focus tile left', - description: 'Move focus to the tile to the left', - category: 'View', - scope: 'view', - default: { - mac: [chord('ArrowLeft', { ctrl: true, alt: true })], - windows: [chord('ArrowLeft', { ctrl: true, alt: true })], - }, - }, - { - id: 'tiles.focus.right', - label: 'Focus tile right', - description: 'Move focus to the tile to the right', - category: 'View', - scope: 'view', - default: { - mac: [chord('ArrowRight', { ctrl: true, alt: true })], - windows: [chord('ArrowRight', { ctrl: true, alt: true })], - }, - }, - { - id: 'tiles.focus.up', - label: 'Focus tile up', - description: 'Move focus to the tile above', - category: 'View', - scope: 'view', - default: { - mac: [chord('ArrowUp', { ctrl: true, alt: true })], - windows: [chord('ArrowUp', { ctrl: true, alt: true })], - }, - }, - { - id: 'tiles.focus.down', - label: 'Focus tile down', - description: 'Move focus to the tile below', - category: 'View', - scope: 'view', - default: { - mac: [chord('ArrowDown', { ctrl: true, alt: true })], - windows: [chord('ArrowDown', { ctrl: true, alt: true })], - }, - }, - { - id: 'tiles.toggle.expand', - label: 'Toggle tile expand', - description: 'Expand the active tile or restore it', - category: 'View', - scope: 'view', - default: { - mac: [chord('Enter', { meta: true, alt: true })], - windows: [chord('Enter', { ctrl: true, alt: true })], - }, - }, - { - id: 'tiles.balance', - label: 'Balance tile sizes', - description: 'Equalize the tile sizes in the current layout', - category: 'View', - scope: 'view', - default: { - mac: [chord('Enter', { meta: true, alt: true, shift: true })], - windows: [chord('Enter', { ctrl: true, alt: true, shift: true })], - }, - }, - { - id: 'tiles.close.active', - label: 'Close active tile', - description: 'Close the active tile', - category: 'View', - scope: 'view', - default: { - mac: [chord('Backspace', { meta: true, alt: true })], - windows: [chord('Backspace', { ctrl: true, alt: true })], - }, - }, - { - id: 'tiles.close.others', - label: 'Close other tiles', - description: 'Close all tiles except the active one', - category: 'View', - scope: 'view', - default: { - mac: [chord('o', { meta: true, alt: true })], - windows: [chord('o', { ctrl: true, alt: true })], - }, - }, - { - id: 'tiles.open.editor', - label: 'Open editor tile', - description: 'Open an editor tile for the focused document', - category: 'View', - scope: 'view', - default: { - mac: [chord('e', { meta: true, alt: true })], - windows: [chord('e', { ctrl: true, alt: true })], - }, - }, - { - id: 'tiles.open.preview', - label: 'Open preview tile', - description: 'Open a preview tile for the focused document', - category: 'View', - scope: 'view', - default: { - mac: [chord('v', { meta: true, alt: true })], - windows: [chord('v', { ctrl: true, alt: true })], - }, - }, { id: 'editor.sync-scroll.toggle', label: 'Toggle scroll sync', diff --git a/app/src/shared/lib/document-workspace-events.ts b/app/src/shared/lib/document-workspace-events.ts new file mode 100644 index 00000000..5d4684ea --- /dev/null +++ b/app/src/shared/lib/document-workspace-events.ts @@ -0,0 +1,17 @@ +export const OPEN_DOCUMENT_PLUGIN_PANE_EVENT = 'refmd:document-workspace:open-plugin-pane' + +export type OpenDocumentPluginPaneDetail = { + documentId: string + paneKey?: string +} + +export function dispatchOpenDocumentPluginPane(documentId: string, paneKey?: string) { + if (typeof window === 'undefined') return + const id = (documentId || '').trim() + if (!id) return + window.dispatchEvent( + new CustomEvent(OPEN_DOCUMENT_PLUGIN_PANE_EVENT, { + detail: paneKey ? { documentId: id, paneKey } : { documentId: id }, + }), + ) +} diff --git a/app/src/shared/lib/mosaic-events.ts b/app/src/shared/lib/mosaic-events.ts deleted file mode 100644 index 15189d48..00000000 --- a/app/src/shared/lib/mosaic-events.ts +++ /dev/null @@ -1,111 +0,0 @@ -export const OPEN_PREVIEW_TILE_EVENT = 'refmd:mosaic:open-preview-tile' -export const OPEN_EDITOR_TILE_EVENT = 'refmd:mosaic:open-editor-tile' -export const OPEN_BACKLINKS_TILE_EVENT = 'refmd:mosaic:open-backlinks-tile' -export const OPEN_DOCUMENT_PLUGIN_PANE_EVENT = 'refmd:mosaic:open-document-plugin-pane' -export const MOSAIC_SCROLL_SYNC_EVENT = 'refmd:mosaic:scroll-sync' -export const MOSAIC_SET_VIEW_MODE_EVENT = 'refmd:mosaic:set-view-mode' -export const MOSAIC_CURRENT_VIEW_MODE_EVENT = 'refmd:mosaic:current-view-mode' - -export type OpenPreviewTileDetail = { - documentId: string - splitMode?: 'auto' | 'row' | 'column' -} - -export function dispatchOpenPreviewTile(documentId: string, splitMode?: OpenPreviewTileDetail['splitMode']) { - if (typeof window === 'undefined') return - const normalizedMode = splitMode === 'auto' || splitMode === 'row' || splitMode === 'column' ? splitMode : undefined - window.dispatchEvent( - new CustomEvent(OPEN_PREVIEW_TILE_EVENT, { - detail: normalizedMode ? { documentId, splitMode: normalizedMode } : { documentId }, - }), - ) -} - -export type OpenEditorTileDetail = { - documentId: string -} - -export function dispatchOpenEditorTile(documentId: string) { - if (typeof window === 'undefined') return - window.dispatchEvent( - new CustomEvent(OPEN_EDITOR_TILE_EVENT, { - detail: { documentId }, - }), - ) -} - -export type OpenBacklinksTileDetail = { - documentId: string -} - -export function dispatchOpenBacklinksTile(documentId: string) { - if (typeof window === 'undefined') return - window.dispatchEvent( - new CustomEvent(OPEN_BACKLINKS_TILE_EVENT, { - detail: { documentId }, - }), - ) -} - -export type OpenDocumentPluginPaneDetail = { - documentId: string - paneKey?: string -} - -export function dispatchOpenDocumentPluginPane(documentId: string, paneKey?: string) { - if (typeof window === 'undefined') return - const id = (documentId || '').trim() - if (!id) return - window.dispatchEvent( - new CustomEvent(OPEN_DOCUMENT_PLUGIN_PANE_EVENT, { - detail: paneKey ? { documentId: id, paneKey } : { documentId: id }, - }), - ) -} - -export type MosaicScrollSyncDetail = { - groupId: string - source: 'editor' | 'preview' - line?: number -} - -export function dispatchMosaicScrollSync(detail: MosaicScrollSyncDetail) { - if (typeof window === 'undefined') return - window.dispatchEvent( - new CustomEvent(MOSAIC_SCROLL_SYNC_EVENT, { - detail, - }), - ) -} - -export type MosaicSetViewModeDetail = { - documentId: string - mode: 'editor' | 'split' | 'preview' -} - -export function dispatchMosaicSetViewMode(documentId: string, mode: MosaicSetViewModeDetail['mode']) { - if (typeof window === 'undefined') return - const id = (documentId || '').trim() - if (!id) return - window.dispatchEvent( - new CustomEvent(MOSAIC_SET_VIEW_MODE_EVENT, { - detail: { documentId: id, mode }, - }), - ) -} - -export type MosaicCurrentViewModeDetail = { - documentId: string - mode: 'editor' | 'split' | 'preview' -} - -export function dispatchMosaicCurrentViewMode(documentId: string, mode: MosaicCurrentViewModeDetail['mode']) { - if (typeof window === 'undefined') return - const id = (documentId || '').trim() - if (!id) return - window.dispatchEvent( - new CustomEvent(MOSAIC_CURRENT_VIEW_MODE_EVENT, { - detail: { documentId: id, mode }, - }), - ) -} diff --git a/app/src/styles.css b/app/src/styles.css index b2df43c1..89337e97 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -45,311 +45,6 @@ --radius-xl: calc(var(--radius) + 4px); } -/* react-mosaic-component (custom theme; no upstream CSS import) */ -.refmd-mosaic-theme.mosaic { - --refmd-mosaic-gap: 10px; - height: 100%; - width: 100%; - position: relative; -} - -.refmd-mosaic-theme.mosaic, -.refmd-mosaic-theme.mosaic * { - box-sizing: border-box; -} - -.refmd-mosaic-theme .mosaic-zero-state { - position: absolute; - top: 6px; - right: 6px; - bottom: 6px; - left: 6px; - z-index: 1; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--background); -} - -.refmd-mosaic-theme .mosaic-root { - position: absolute; - /* Keep tile content flush to the container top (matches pre-tile layout), - while still creating gaps between tiles via per-tile margins. */ - top: calc(var(--refmd-mosaic-gap) * -1); - right: calc(var(--refmd-mosaic-gap) * -1); - bottom: calc(var(--refmd-mosaic-gap) * -1); - left: calc(var(--refmd-mosaic-gap) * -1); -} - -.refmd-mosaic-theme .mosaic-tile { - position: absolute; - margin: var(--refmd-mosaic-gap); -} - -.refmd-mosaic-theme .mosaic-tile > * { - height: 100%; - width: 100%; -} - -.refmd-mosaic-theme .mosaic-window, -.refmd-mosaic-theme .mosaic-preview { - position: relative; - display: flex; - flex-direction: column; - overflow: visible; - min-width: 0; - border: none; - border-radius: 0; - background: transparent; - box-shadow: none; - backdrop-filter: none; -} - -.refmd-mosaic-theme .refmd-mosaic-panel { - height: 100%; - width: 100%; - min-height: 0; - min-width: 0; - display: flex; - flex-direction: column; - overflow: hidden; - border: 1px solid color-mix(in oklab, var(--border) 55%, transparent); - border-radius: 24px; - box-shadow: - 0 1px 2px rgba(0, 0, 0, 0.06), - 0 10px 30px rgba(0, 0, 0, 0.12); -} - -.refmd-mosaic-theme .mosaic-window .mosaic-window-toolbar, -.refmd-mosaic-theme .mosaic-preview .mosaic-window-toolbar { - z-index: 4; - position: absolute; - top: 0; - right: 0; - left: 0; - height: 0; - padding: 0; - background: transparent; - color: var(--header-foreground); - border-bottom: none; - overflow: visible; -} - -.refmd-mosaic-theme .mosaic-window .mosaic-window-toolbar.draggable, -.refmd-mosaic-theme .mosaic-preview .mosaic-window-toolbar.draggable { - cursor: move; -} - -.refmd-mosaic-theme .mosaic-window .mosaic-window-title, -.refmd-mosaic-theme .mosaic-preview .mosaic-window-title { - position: absolute; - top: 0; - right: 0; - left: 0; - height: 18px; - opacity: 0; - overflow: hidden; - user-select: none; - cursor: move; -} - -.refmd-mosaic-theme .mosaic-window .mosaic-window-controls, -.refmd-mosaic-theme .mosaic-preview .mosaic-window-controls { - position: absolute; - top: 8px; - right: 8px; - display: flex; - flex-direction: row; - justify-content: flex-end; - gap: 6px; - align-items: center; - padding: 4px; - border-radius: 999px; - border: 1px solid color-mix(in oklab, var(--border) 55%, transparent); - background: color-mix(in oklab, var(--background) 88%, transparent); - backdrop-filter: blur(6px); - overflow: hidden; - transition: max-width 160ms ease; -} - -.refmd-mosaic-theme .mosaic-window .mosaic-window-controls .mosaic-controls-toggle, -.refmd-mosaic-theme .mosaic-preview .mosaic-window-controls .mosaic-controls-toggle { - font-size: 18px; - line-height: 1; -} - -@media (hover: hover) and (pointer: fine) { - .refmd-mosaic-theme .mosaic-window .mosaic-window-controls, - .refmd-mosaic-theme .mosaic-preview .mosaic-window-controls { - max-width: 46px; - /* In collapsed state, show only the “more” button without the pill background. */ - border-color: transparent; - background: transparent; - backdrop-filter: none; - } - - .refmd-mosaic-theme .mosaic-window .mosaic-window-controls:hover, - .refmd-mosaic-theme .mosaic-window .mosaic-window-controls:focus-within, - .refmd-mosaic-theme .mosaic-preview .mosaic-window-controls:hover, - .refmd-mosaic-theme .mosaic-preview .mosaic-window-controls:focus-within { - max-width: 260px; - border-color: color-mix(in oklab, var(--border) 55%, transparent); - background: color-mix(in oklab, var(--background) 88%, transparent); - backdrop-filter: blur(6px); - } - - .refmd-mosaic-theme .mosaic-window .mosaic-window-controls > :not(.mosaic-controls-toggle), - .refmd-mosaic-theme .mosaic-preview .mosaic-window-controls > :not(.mosaic-controls-toggle) { - visibility: hidden; - opacity: 0; - transform: translateX(6px); - transition: opacity 120ms ease, transform 120ms ease; - pointer-events: none; - } - - .refmd-mosaic-theme .mosaic-window .mosaic-window-controls:hover > :not(.mosaic-controls-toggle), - .refmd-mosaic-theme .mosaic-window .mosaic-window-controls:focus-within > :not(.mosaic-controls-toggle), - .refmd-mosaic-theme .mosaic-preview .mosaic-window-controls:hover > :not(.mosaic-controls-toggle), - .refmd-mosaic-theme .mosaic-preview .mosaic-window-controls:focus-within > :not(.mosaic-controls-toggle) { - visibility: visible; - opacity: 1; - transform: translateX(0); - pointer-events: auto; - } -} - -.refmd-mosaic-theme .mosaic-window .mosaic-window-controls .separator, -.refmd-mosaic-theme .mosaic-preview .mosaic-window-controls .separator { - height: 18px; - border-left: 1px solid var(--border); -} - -.refmd-mosaic-theme .mosaic-default-control { - height: 28px; - width: 28px; - min-width: 28px; - padding: 0; - border-radius: 999px; - border: 1px solid var(--border); - background: color-mix(in oklab, var(--background) 75%, transparent); - color: var(--foreground); - font-size: 14px; - line-height: 1; - display: inline-grid; - place-items: center; -} - -.refmd-mosaic-theme .mosaic-default-control:hover { - background: color-mix(in oklab, var(--accent) 18%, var(--background)); -} - -.refmd-mosaic-theme .mosaic-default-control:active { - background: color-mix(in oklab, var(--accent) 28%, var(--background)); -} - -.refmd-mosaic-theme .mosaic-window .mosaic-window-body, -.refmd-mosaic-theme .mosaic-preview .mosaic-window-body { - position: relative; - flex: 1; - min-width: 0; - min-height: 0; - height: 0; - background: transparent; - z-index: 1; - overflow: visible; -} - -.refmd-mosaic-theme .mosaic-window .mosaic-preview, -.refmd-mosaic-theme .mosaic-preview .mosaic-preview { - position: absolute; - inset: 0; - height: 100%; - width: 100%; - z-index: 0; - overflow: hidden; - pointer-events: none; -} - -.refmd-mosaic-theme .mosaic-window .mosaic-window-body-overlay, -.refmd-mosaic-theme .mosaic-preview .mosaic-window-body-overlay { - position: absolute; - inset: 0; - opacity: 0; - background: var(--background); - display: none; - z-index: 2; -} - -.refmd-mosaic-theme .mosaic-window .mosaic-window-additional-actions-bar, -.refmd-mosaic-theme .mosaic-preview .mosaic-window-additional-actions-bar { - display: none; -} - -.refmd-mosaic-theme .mosaic-split { - position: absolute; - z-index: 3; - touch-action: none; -} - -.refmd-mosaic-theme .mosaic-split .mosaic-split-line { - position: absolute; - background: var(--border); - opacity: 0.9; -} - -.refmd-mosaic-theme .mosaic-split.-row { - margin-left: -5px; - width: 10px; - cursor: ew-resize; -} - -.refmd-mosaic-theme .mosaic-split.-row .mosaic-split-line { - top: 0; - bottom: 0; - left: 5px; - right: 5px; -} - -.refmd-mosaic-theme .mosaic-split.-column { - margin-top: -5px; - height: 10px; - cursor: ns-resize; -} - -.refmd-mosaic-theme .mosaic-split.-column .mosaic-split-line { - left: 0; - right: 0; - top: 5px; - bottom: 5px; -} - -.refmd-mosaic-theme.mosaic-drop-target .drop-target-container, -.refmd-mosaic-theme .mosaic-drop-target .drop-target-container { - position: absolute; - inset: 0; - display: none; -} - -.refmd-mosaic-theme.mosaic-drop-target.drop-target-hover .drop-target-container, -.refmd-mosaic-theme .mosaic-drop-target.drop-target-hover .drop-target-container, -.refmd-mosaic-theme .mosaic-drop-target .drop-target-container.-dragging { - display: block; -} - -.refmd-mosaic-theme.mosaic-drop-target .drop-target-container .drop-target, -.refmd-mosaic-theme .mosaic-drop-target .drop-target-container .drop-target { - position: absolute; - inset: 0; - background: color-mix(in oklab, var(--accent) 10%, transparent); - border: 2px solid color-mix(in oklab, var(--accent) 55%, var(--border)); - border-radius: 10px; - opacity: 0; - z-index: 5; -} - -.refmd-mosaic-theme .mosaic-drop-target .drop-target-container .drop-target.drop-target-hover { - opacity: 1; -} - :root { /* Inform the UA that light colors are the default; overridden by .dark */ color-scheme: light; diff --git a/app/src/widgets/document/DocumentMosaicWorkspace.tsx b/app/src/widgets/document/DocumentMosaicWorkspace.tsx deleted file mode 100644 index 07c34597..00000000 --- a/app/src/widgets/document/DocumentMosaicWorkspace.tsx +++ /dev/null @@ -1,2898 +0,0 @@ -"use client" - -import { useQuery } from '@tanstack/react-query' -import { useNavigate } from '@tanstack/react-router' -import { Columns2, Eye, FileCode, Loader2, Maximize2, MoreHorizontal, X } from 'lucide-react' -import { useCallback, useContext, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react' -import { - Mosaic, - MosaicContext, - MosaicWindow, - MosaicWindowContext, - Separator, - createExpandUpdate, - getLeaves, - isParent, - updateTree, - type MosaicNode, - type MosaicParent, - type MosaicPath, -} from 'react-mosaic-component' -import { toast } from 'sonner' - -import { useRealtime } from '@/shared/contexts/realtime-context' -import { useShortcut } from '@/shared/hooks/use-shortcut' -import { - MOSAIC_SCROLL_SYNC_EVENT, - OPEN_BACKLINKS_TILE_EVENT, - OPEN_DOCUMENT_PLUGIN_PANE_EVENT, - OPEN_EDITOR_TILE_EVENT, - OPEN_PREVIEW_TILE_EVENT, - MOSAIC_SET_VIEW_MODE_EVENT, - dispatchOpenDocumentPluginPane, - dispatchMosaicScrollSync, - dispatchMosaicSetViewMode, - dispatchMosaicCurrentViewMode, - type MosaicScrollSyncDetail, -} from '@/shared/lib/mosaic-events' - -import { fetchDocumentContent, fetchDocumentMeta } from '@/entities/document' -import { browseShare } from '@/entities/share' - -import { useAuthContext } from '@/features/auth' -import { BacklinksPanel } from '@/features/document-backlinks' -import { EditorOverlay, MarkdownEditor, PreviewPane, useCollaborativeDocument } from '@/features/edit-document' -import { mountResolvedPlugin, resolvePluginForDocument, resolvePluginForDocumentById, type DocumentPluginMatch } from '@/features/plugins' -import { renderDocumentPaneIcon } from '@/features/plugins/lib/pane-icons' -import { DocumentEditorPanes, type DocumentEditorPaneHostState } from '@/features/plugins/model/useDocumentEditorPlugins' -import { mountSplitEditorPreviewStage } from '@/features/plugins/ui/SplitEditorHost' - -import DocumentPage, { type DocumentLoaderData, type DocumentPageProps, type DocumentPageRenderContext } from './DocumentPage' - -type TileKey = `tile:${string}` -type TileMode = 'editor' | 'preview' | 'backlinks' | 'plugin-pane' -type BaseTileSpec = { - mode: TileMode - documentId: string -} -type EditorPreviewTileSpec = BaseTileSpec & { - mode: 'editor' | 'preview' - syncGroupId?: string -} -type BacklinksTileSpec = BaseTileSpec & { - mode: 'backlinks' -} -type PluginPaneTileSpec = BaseTileSpec & { - mode: 'plugin-pane' - pluginPaneKey: string -} -type TileSpec = EditorPreviewTileSpec | BacklinksTileSpec | PluginPaneTileSpec - -type MosaicState = { - layout: MosaicNode | null - tiles: Record -} - -type ShareScope = 'document' | 'folder' - -const STORAGE_KEY_PREFIX = 'refmd-document-mosaic-state-v3' -const FORCE_FLOATING_TOC_MAX_WIDTH_PX = 1024 -const EXPAND_PERCENTAGE = 80 -const UNEXPAND_PERCENTAGE = 50 -const DOCUMENT_PLUGIN_PANE_SPLIT_PERCENTAGE = 72 -const PLUGIN_USES_SPLIT_EDITOR_EVENT = 'refmd:plugin:uses-split-editor' - -const splitCapablePluginDocIds = new Set() -const splitCapablePluginDocSubscribers = new Set<() => void>() -let splitCapablePluginDocListening = false - -function emitSplitCapablePluginDocUpdate() { - for (const listener of splitCapablePluginDocSubscribers) { - try { - listener() - } catch { - /* noop */ - } - } -} - -function markSplitCapablePluginDoc(docId: string) { - const id = docId.trim() - if (!id) return - if (splitCapablePluginDocIds.has(id)) return - splitCapablePluginDocIds.add(id) - emitSplitCapablePluginDocUpdate() -} - -function ensureSplitCapablePluginDocListener() { - if (splitCapablePluginDocListening) return - if (typeof window === 'undefined') return - splitCapablePluginDocListening = true - window.addEventListener( - PLUGIN_USES_SPLIT_EDITOR_EVENT, - ((event: Event) => { - const detail = (event as CustomEvent<{ docId?: string }>).detail - const docId = typeof detail?.docId === 'string' ? detail.docId.trim() : '' - if (!docId) return - markSplitCapablePluginDoc(docId) - }) as EventListener, - ) -} - -function buildMosaicStorageKey(args: { userId: string | null | undefined; workspaceId: string | null | undefined }) { - const userId = typeof args.userId === 'string' ? args.userId.trim() : '' - const workspaceId = typeof args.workspaceId === 'string' ? args.workspaceId.trim() : '' - if (!workspaceId) return null - return `${STORAGE_KEY_PREFIX}:${userId || 'anon'}:${workspaceId}` -} - -function updateParentSplitPercentage( - layout: MosaicNode | null, - path: MosaicPath, - splitPercentage: number, -): MosaicNode | null { - if (!layout) return null - if (path.length === 0) { - if (!isParent(layout)) return layout - const parent = layout as MosaicParent - const current = normalizeSplitPercentage((parent as any).splitPercentage) - const next = normalizeSplitPercentage(splitPercentage) - if (current === next) return layout - return { ...parent, splitPercentage: next } - } - if (!isParent(layout)) return layout - const parent = layout as MosaicParent - const [head, ...rest] = path - if (head === 'first') { - const updated = updateParentSplitPercentage(parent.first, rest as MosaicPath, splitPercentage) - if (updated === parent.first) return layout - return { ...parent, first: updated as any } - } - const updated = updateParentSplitPercentage(parent.second, rest as MosaicPath, splitPercentage) - if (updated === parent.second) return layout - return { ...parent, second: updated as any } -} - -function tileControlsToggle(key = 'more') { - return ( - - ) -} - -function TileCloseButton({ onBeforeClose }: { onBeforeClose?: () => void } = {}) { - const mosaic = useContext(MosaicContext) - const mosaicWindow = useContext(MosaicWindowContext) - - return ( - - ) -} - -function makeTileKey(): TileKey { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return `tile:${crypto.randomUUID()}` as TileKey - } - return `tile:${Math.random().toString(36).slice(2)}` as TileKey -} - -function makeSyncGroupId(): string { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return `split:${crypto.randomUUID()}` - } - return `split:${Math.random().toString(36).slice(2)}` -} - -function replaceNodeAtPath( - layout: MosaicNode | null, - path: MosaicPath, - replacement: MosaicNode, -): MosaicNode { - if (!layout) return replacement - if (path.length === 0) return replacement - if (!isParent(layout)) return layout - const parent = layout as MosaicParent - const [head, ...rest] = path - if (head === 'first') return { ...parent, first: replaceNodeAtPath(parent.first, rest as MosaicPath, replacement) } - return { ...parent, second: replaceNodeAtPath(parent.second, rest as MosaicPath, replacement) } -} - -function findPathToTile(layout: MosaicNode | null, target: TileKey): MosaicPath | null { - if (!layout) return null - if (!isParent(layout)) return (layout as TileKey) === target ? ([] as MosaicPath) : null - const parent = layout as MosaicParent - const inFirst = findPathToTile(parent.first, target) - if (inFirst) return ['first', ...inFirst] - const inSecond = findPathToTile(parent.second, target) - if (inSecond) return ['second', ...inSecond] - return null -} - -function removeLeaf(layout: MosaicNode | null, target: TileKey): MosaicNode | null { - if (!layout) return null - if (!isParent(layout)) return (layout as TileKey) === target ? null : layout - const parent = layout as MosaicParent - const first = removeLeaf(parent.first, target) - const second = removeLeaf(parent.second, target) - if (!first && !second) return null - if (!first) return second - if (!second) return first - if (first === parent.first && second === parent.second) return parent - return { ...parent, first, second } -} - -function getLeavesSafe(layout: MosaicNode | null): TileKey[] { - if (!layout) return [] - try { - return getLeaves(layout) - } catch { - return [] - } -} - -function normalizeSplitPercentage(value: unknown): number { - if (typeof value !== 'number' || !Number.isFinite(value)) return 50 - return Math.min(100, Math.max(0, value)) -} - -function insertLeafAtRight(layout: MosaicNode | null, leaf: TileKey): MosaicNode { - if (!layout) return leaf - return { direction: 'row', first: layout, second: leaf, splitPercentage: 50 } -} - -function insertDocumentPluginPaneAtRight(layout: MosaicNode | null, leaf: TileKey): MosaicNode { - if (!layout) return leaf - return { - direction: 'row', - first: layout, - second: leaf, - splitPercentage: DOCUMENT_PLUGIN_PANE_SPLIT_PERCENTAGE, - } -} - -type InsertSplitMode = 'auto' | 'row' | 'column' - -function insertLeafWithMode( - layout: MosaicNode | null, - leaf: TileKey, - preferredLeaf: TileKey | undefined, - mode: InsertSplitMode, -): MosaicNode { - if (mode === 'row') return insertLeafAtRight(layout, leaf) - return insertLeafBsp(layout, leaf, preferredLeaf, mode) -} - -type BspRect = { x: number; y: number; w: number; h: number } -type BspLeafPane = { leaf: TileKey; path: MosaicPath; rect: BspRect } - -function getRootRect(): BspRect { - if (typeof window === 'undefined') return { x: 0, y: 0, w: 1, h: 1 } - const w = window.innerWidth - const h = window.innerHeight - if (!w || !h) return { x: 0, y: 0, w: 1, h: 1 } - const aspect = w / h - if (aspect >= 1) return { x: 0, y: 0, w: aspect, h: 1 } - return { x: 0, y: 0, w: 1, h: 1 / aspect } -} - -function collectLeafPanes( - node: MosaicNode, - rect: BspRect, - path: MosaicPath, - out: BspLeafPane[], -) { - if (!isParent(node)) { - out.push({ leaf: node as TileKey, path, rect }) - return - } - - const parent = node as MosaicParent - const split = normalizeSplitPercentage((parent as any).splitPercentage) / 100 - if (parent.direction === 'row') { - const firstRect: BspRect = { x: rect.x, y: rect.y, w: rect.w * split, h: rect.h } - const secondRect: BspRect = { x: rect.x + firstRect.w, y: rect.y, w: rect.w * (1 - split), h: rect.h } - collectLeafPanes(parent.first, firstRect, [...path, 'first'], out) - collectLeafPanes(parent.second, secondRect, [...path, 'second'], out) - return - } - - const firstRect: BspRect = { x: rect.x, y: rect.y, w: rect.w, h: rect.h * split } - const secondRect: BspRect = { x: rect.x, y: rect.y + firstRect.h, w: rect.w, h: rect.h * (1 - split) } - collectLeafPanes(parent.first, firstRect, [...path, 'first'], out) - collectLeafPanes(parent.second, secondRect, [...path, 'second'], out) -} - -function pickBspTarget(layout: MosaicNode, preferredLeaf?: TileKey): BspLeafPane { - const panes: BspLeafPane[] = [] - collectLeafPanes(layout, getRootRect(), [] as MosaicPath, panes) - - if (preferredLeaf) { - const preferred = panes.find((pane) => pane.leaf === preferredLeaf) - if (preferred) { - const preferredArea = preferred.rect.w * preferred.rect.h - let maxArea = preferredArea - for (const pane of panes) { - const area = pane.rect.w * pane.rect.h - if (area > maxArea) maxArea = area - } - // If preferred is tied for "largest" (within epsilon), split it for more intuitive placement. - const epsilon = 1e-6 - if (preferredArea + epsilon >= maxArea) return preferred - } - } - - // BSPwm-style: split the largest visible pane by area. - let best = panes[0] - let bestArea = (best?.rect.w ?? 0) * (best?.rect.h ?? 0) - for (const pane of panes) { - const area = pane.rect.w * pane.rect.h - if (area > bestArea) { - best = pane - bestArea = area - } - } - return best -} - -function computeBspSplitDirection(rect: BspRect): 'row' | 'column' { - // BSPwm-style: split along the longer axis (vertical split when pane is wider). - return rect.w >= rect.h ? 'row' : 'column' -} - -function resolveInsertSplitDirection(mode: InsertSplitMode, rect: BspRect): 'row' | 'column' { - if (mode === 'row') return 'row' - if (mode === 'column') return 'column' - return computeBspSplitDirection(rect) -} - -function insertLeafBsp( - layout: MosaicNode | null, - leaf: TileKey, - preferredLeaf?: TileKey, - mode: InsertSplitMode = 'auto', -): MosaicNode { - if (!layout) return leaf - if (!isParent(layout)) { - const rootRect = getRootRect() - return { - direction: resolveInsertSplitDirection(mode, rootRect), - first: layout, - second: leaf, - splitPercentage: 50, - } - } - const target = pickBspTarget(layout, preferredLeaf) - const direction = resolveInsertSplitDirection(mode, target.rect) - const replacement: MosaicNode = { - direction, - first: target.leaf, - second: leaf, - splitPercentage: 50, - } - return replaceNodeAtPath(layout, target.path, replacement) -} - -type SwapDirection = 'left' | 'right' | 'up' | 'down' - -function overlaps(a0: number, a1: number, b0: number, b1: number) { - return Math.min(a1, b1) - Math.max(a0, b0) > 0 -} - -function pickNeighborLeaf(panes: BspLeafPane[], fromLeaf: TileKey, direction: SwapDirection): TileKey | null { - const from = panes.find((pane) => pane.leaf === fromLeaf) - if (!from) return null - - const fromCx = from.rect.x + from.rect.w / 2 - const fromCy = from.rect.y + from.rect.h / 2 - - const isOverlapMatch = (pane: BspLeafPane) => { - if (direction === 'left' || direction === 'right') { - return overlaps(from.rect.y, from.rect.y + from.rect.h, pane.rect.y, pane.rect.y + pane.rect.h) - } - return overlaps(from.rect.x, from.rect.x + from.rect.w, pane.rect.x, pane.rect.x + pane.rect.w) - } - - const candidates = panes.filter((pane) => pane.leaf !== fromLeaf) - - const score = (pane: BspLeafPane) => { - const cx = pane.rect.x + pane.rect.w / 2 - const cy = pane.rect.y + pane.rect.h / 2 - if (direction === 'left') return cx < fromCx ? cx : -Infinity - if (direction === 'right') return cx > fromCx ? -cx : -Infinity - if (direction === 'up') return cy < fromCy ? cy : -Infinity - return cy > fromCy ? -cy : -Infinity - } - - const pick = (list: BspLeafPane[]) => { - let best: BspLeafPane | null = null - let bestScore = -Infinity - for (const pane of list) { - const s = score(pane) - if (s > bestScore) { - best = pane - bestScore = s - } - } - return best?.leaf ?? null - } - - const overlapCandidates = candidates.filter((pane) => isOverlapMatch(pane)) - return pick(overlapCandidates) ?? pick(candidates) -} - -function ensureLeafInLayout( - layout: MosaicNode | null, - leaf: TileKey, - preferredLeaf?: TileKey, - mode: InsertSplitMode = 'auto', -): MosaicNode { - const leaves = getLeavesSafe(layout) - if (leaves.includes(leaf)) return layout ?? leaf - return insertLeafWithMode(layout, leaf, preferredLeaf, mode) -} - -function maybeBuildTwoDocSplitGrid( - layout: MosaicNode | null, - tiles: Record, -): MosaicNode | null { - if (!layout) return null - const leaves = getLeavesSafe(layout) - if (leaves.length !== 4) return null - - const docOrder: string[] = [] - const groups = new Map() - for (const leaf of leaves) { - const spec = tiles[leaf] - if (!spec) return null - if (spec.mode !== 'editor' && spec.mode !== 'preview') return null - const docId = spec.documentId - if (!groups.has(docId)) { - groups.set(docId, {}) - docOrder.push(docId) - } - const group = groups.get(docId)! - if (spec.mode === 'editor') group.editor = leaf - else group.preview = leaf - } - - if (groups.size !== 2) return null - for (const group of groups.values()) { - if (!group.editor || !group.preview) return null - } - - const makeRow = (docId: string): MosaicNode => { - const group = groups.get(docId)! - return { - direction: 'row', - first: group.editor!, - second: group.preview!, - splitPercentage: 50, - } - } - - const [firstDoc, secondDoc] = docOrder - if (!firstDoc || !secondDoc) return null - return { - direction: 'column', - first: makeRow(firstDoc), - second: makeRow(secondDoc), - splitPercentage: 50, - } -} - -function pruneLayout( - layout: MosaicNode | null, - isValidLeaf: (leaf: TileKey) => boolean, -): MosaicNode | null { - if (!layout) return null - if (!isParent(layout)) { - const leaf = layout as TileKey - return isValidLeaf(leaf) ? leaf : null - } - const parent = layout as MosaicParent - const first = pruneLayout(parent.first, isValidLeaf) - const second = pruneLayout(parent.second, isValidLeaf) - if (!first && !second) return null - if (!first) return second - if (!second) return first - if (first === parent.first && second === parent.second) return parent - return { ...parent, first, second } -} - -function balanceLayoutSplits(layout: MosaicNode): MosaicNode { - const equalize = (node: MosaicNode): { node: MosaicNode; leafCount: number } => { - if (!isParent(node)) return { node, leafCount: 1 } - const parent = node as MosaicParent - const first = equalize(parent.first) - const second = equalize(parent.second) - const total = first.leafCount + second.leafCount - const nextSplit = total > 0 ? (first.leafCount / total) * 100 : 50 - const currentSplit = normalizeSplitPercentage((parent as any).splitPercentage) - const normalizedNext = normalizeSplitPercentage(nextSplit) - const epsilon = 1e-6 - const sameSplit = Math.abs(currentSplit - normalizedNext) < epsilon - const nextNode = - first.node === parent.first && second.node === parent.second && sameSplit - ? node - : { ...parent, first: first.node, second: second.node, splitPercentage: normalizedNext } - return { node: nextNode, leafCount: total } - } - - return equalize(layout).node -} - -function defaultState(activeDocumentId: string): MosaicState { - const editorKey = makeTileKey() - const previewKey = makeTileKey() - const groupId = makeSyncGroupId() - return { - layout: { direction: 'row', first: editorKey, second: previewKey, splitPercentage: 50 }, - tiles: { - [editorKey]: { mode: 'editor', documentId: activeDocumentId, syncGroupId: groupId }, - [previewKey]: { mode: 'preview', documentId: activeDocumentId, syncGroupId: groupId }, - }, - } -} - -function deriveDocumentViewMode(documentId: string, tiles: Record): 'editor' | 'split' | 'preview' { - const id = documentId.trim() - if (!id) return 'editor' - let hasEditor = false - let hasPreview = false - for (const spec of Object.values(tiles)) { - if (spec.documentId !== id) continue - if (spec.mode === 'editor') hasEditor = true - else if (spec.mode === 'preview') hasPreview = true - if (hasEditor && hasPreview) return 'split' - } - if (hasEditor) return 'editor' - if (hasPreview) return 'preview' - return 'editor' -} - -function sanitizeState(state: MosaicState, activeDocumentId: string): MosaicState { - const prunedLayout = pruneLayout(state.layout, (leaf) => Boolean(state.tiles[leaf])) - const leaves = getLeavesSafe(prunedLayout) - const nextTiles: Record = {} - for (const leaf of leaves) { - const spec = state.tiles[leaf] - if (spec && typeof spec.documentId === 'string' && spec.documentId.trim()) { - const trimmedId = spec.documentId.trim() - const rawGroupId = (spec as any).syncGroupId - const trimmedGroupId = typeof rawGroupId === 'string' ? rawGroupId.trim() : '' - - const needsDocUpdate = trimmedId !== spec.documentId - const needsGroupUpdate = - typeof rawGroupId === 'string' - ? (trimmedGroupId ? trimmedGroupId !== rawGroupId : rawGroupId.length > 0) - : false - - if (!needsDocUpdate && !needsGroupUpdate) { - nextTiles[leaf] = spec - continue - } - - const nextSpec: TileSpec = { ...spec, documentId: trimmedId } - if (nextSpec.mode === 'editor' || nextSpec.mode === 'preview') { - if (trimmedGroupId) nextSpec.syncGroupId = trimmedGroupId - else delete (nextSpec as any).syncGroupId - } - nextTiles[leaf] = nextSpec - } - } - - // Ensure editor/preview tiles for the same document share a sync group. - let needsSyncUpdate = false - const byDoc = new Map() - for (const leaf of leaves) { - const spec = nextTiles[leaf] - if (!spec) continue - if (spec.mode !== 'editor' && spec.mode !== 'preview') continue - const docId = spec.documentId - let bucket = byDoc.get(docId) - if (!bucket) { - bucket = { editors: [], previews: [] } - byDoc.set(docId, bucket) - } - if (spec.mode === 'editor') bucket.editors.push(leaf) - else bucket.previews.push(leaf) - } - - for (const [, bucket] of byDoc) { - if (bucket.editors.length === 0 || bucket.previews.length === 0) continue - - const editorGroups = new Set( - bucket.editors - .map((key) => { - const spec = nextTiles[key] - return spec?.mode === 'editor' ? spec.syncGroupId : undefined - }) - .filter(Boolean) as string[], - ) - const previewGroups = new Set( - bucket.previews - .map((key) => { - const spec = nextTiles[key] - return spec?.mode === 'preview' ? spec.syncGroupId : undefined - }) - .filter(Boolean) as string[], - ) - - let groupId: string | null = null - for (const candidate of editorGroups) { - if (previewGroups.has(candidate)) { - groupId = candidate - break - } - } - if (!groupId) { - groupId = editorGroups.values().next().value ?? previewGroups.values().next().value ?? null - } - if (!groupId) groupId = makeSyncGroupId() - - for (const key of [...bucket.editors, ...bucket.previews]) { - const spec = nextTiles[key] - if (!spec) continue - if (spec.mode !== 'editor' && spec.mode !== 'preview') continue - if (spec.syncGroupId !== groupId) { - nextTiles[key] = { ...spec, syncGroupId: groupId } - needsSyncUpdate = true - } - } - } - if (leaves.length === 0) { - return defaultState(activeDocumentId) - } - if (state.layout === prunedLayout) { - const stateKeys = Object.keys(state.tiles) as TileKey[] - if (stateKeys.length === leaves.length) { - let same = true - for (const key of stateKeys) { - if (!nextTiles[key] || state.tiles[key] !== nextTiles[key]) { - same = false - break - } - } - if (same && !needsSyncUpdate) return state - } - } - return { layout: prunedLayout, tiles: nextTiles } -} - -function sameDocumentEditorPaneHost(a: DocumentEditorPaneHostState | null | undefined, b: DocumentEditorPaneHostState | null | undefined) { - if (a === b) return true - if (!a || !b) return false - if (a.document !== b.document) return false - if (a.editor !== b.editor) return false - if ((a.user?.id ?? null) !== (b.user?.id ?? null)) return false - if ((a.user?.name ?? null) !== (b.user?.name ?? null)) return false - if (a.activePaneKey !== b.activePaneKey) return false - if (a.openPane !== b.openPane || a.closePane !== b.closePane) return false - if (a.panes.length !== b.panes.length) return false - return a.panes.every((pane, index) => { - const next = b.panes[index] - return Boolean( - next && - pane.key === next.key && - pane.title === next.title && - pane.icon === next.icon && - pane.badge === next.badge, - ) - }) -} - -function loadState(activeDocumentId: string, storageKey: string): MosaicState { - if (typeof window === 'undefined') return defaultState(activeDocumentId) - try { - const raw = localStorage.getItem(storageKey) - if (!raw) return defaultState(activeDocumentId) - const parsed = JSON.parse(raw) as unknown - if (!parsed || typeof parsed !== 'object') return defaultState(activeDocumentId) - const candidate = parsed as Partial - if (!candidate.tiles || typeof candidate.tiles !== 'object') return defaultState(activeDocumentId) - const layout = (candidate.layout ?? null) as MosaicNode | null - const tiles = candidate.tiles as Record - return sanitizeState({ layout, tiles }, activeDocumentId) - } catch { - return defaultState(activeDocumentId) - } -} - -function saveState(state: MosaicState, storageKey: string) { - try { - localStorage.setItem(storageKey, JSON.stringify(state)) - } catch { - /* noop */ - } -} - -type Props = Pick & { - shareScope?: ShareScope - isShareMount?: boolean -} - -export default function DocumentMosaicWorkspace(props: Props) { - const { id, loaderData, shareToken, shareScope: shareScopeProp, isShareMount = false, conflictMode } = props - const navigate = useNavigate() - const { documentActions, setDocumentActions } = useRealtime() - const { user, activeWorkspaceId } = useAuthContext() - const shareLinkToken = shareToken && !isShareMount ? shareToken : undefined - const mosaicStorageKey = useMemo(() => { - if (shareLinkToken) return null - return buildMosaicStorageKey({ userId: user?.id ?? null, workspaceId: activeWorkspaceId }) - }, [activeWorkspaceId, shareLinkToken, user?.id]) - const mosaicStorageKeyRef = useRef(mosaicStorageKey) - const [mosaicState, setMosaicState] = useState(() => { - return mosaicStorageKey ? loadState(id, mosaicStorageKey) : defaultState(id) - }) - const [documentPaneHosts, setDocumentPaneHosts] = useState>({}) - const [activeDocumentId, setActiveDocumentId] = useState(id) - const activeDocumentIdRef = useRef(activeDocumentId) - const activeTileRef = useRef<{ tileKey: TileKey; documentId: string; mode: TileMode } | null>(null) - const previousTileKeyRef = useRef(null) - const expandedTileKeyRef = useRef(null) - const [insertSplitMode, setInsertSplitMode] = useState(() => { - if (typeof window === 'undefined') return 'row' - try { - const raw = localStorage.getItem('refmd:mosaic:insert-split-mode') - if (raw === 'row' || raw === 'column' || raw === 'auto') return raw - return 'row' - } catch { - return 'row' - } - }) - const insertSplitModeRef = useRef(insertSplitMode) - const focusRequestIdRef = useRef(0) - const saveTimerRef = useRef(null) - const latestStateRef = useRef(mosaicState) - const shareLinkTokenRef = useRef(shareLinkToken) - const clearSavedLayoutRef = useRef(false) - const lastReportedViewModeRef = useRef<{ docId: string; mode: 'editor' | 'split' | 'preview' } | null>(null) - const lastRouteDocIdRef = useRef(id) - const lastSeenRouteDocIdRef = useRef(id) - const pluginPaneActionPrefix = 'document-plugin-pane:' - - useEffect(() => { - insertSplitModeRef.current = insertSplitMode - }, [insertSplitMode]) - - useEffect(() => { - if (typeof window === 'undefined') return - try { - localStorage.setItem('refmd:mosaic:insert-split-mode', insertSplitMode) - } catch { - // ignore - } - }, [insertSplitMode]) - - useEffect(() => { - latestStateRef.current = mosaicState - }, [mosaicState]) - - useEffect(() => { - if (!mosaicStorageKey) return - if (mosaicStorageKeyRef.current === mosaicStorageKey) return - mosaicStorageKeyRef.current = mosaicStorageKey - activeTileRef.current = null - previousTileKeyRef.current = null - expandedTileKeyRef.current = null - setMosaicState(loadState(id, mosaicStorageKey)) - }, [id, mosaicStorageKey, setMosaicState]) - - useEffect(() => { - const previous = lastRouteDocIdRef.current - lastRouteDocIdRef.current = id - if (!previous || previous === id) return - - setMosaicState((prev) => { - const safe = sanitizeState(prev, id) - const alreadyOpen = Object.values(safe.tiles).some((spec) => spec.documentId === id) - if (alreadyOpen) return safe - - let changed = false - const nextTiles: Record = { ...safe.tiles } - for (const [tileKey, spec] of Object.entries(safe.tiles) as Array<[TileKey, TileSpec]>) { - if (spec.documentId !== previous) continue - nextTiles[tileKey] = { ...spec, documentId: id } - changed = true - } - if (!changed) return safe - - // If the previous focused document was in split view, reset that pair's divider to 50/50 - // so the newly opened document starts balanced. - let nextLayout = safe.layout - const entries = Object.entries(nextTiles) as Array<[TileKey, TileSpec]> - const editorKey = entries.find(([, spec]) => spec.documentId === id && spec.mode === 'editor')?.[0] ?? null - const previewKey = entries.find(([, spec]) => spec.documentId === id && spec.mode === 'preview')?.[0] ?? null - if (editorKey && previewKey && nextLayout) { - const editorPath = findPathToTile(nextLayout, editorKey) - const previewPath = findPathToTile(nextLayout, previewKey) - if (editorPath && previewPath) { - const minLength = Math.min(editorPath.length, previewPath.length) - let idx = 0 - while (idx < minLength && editorPath[idx] === previewPath[idx]) idx += 1 - const lcaPath = editorPath.slice(0, idx) as MosaicPath - const lcaNode = ((): MosaicNode | null => { - let node: MosaicNode | null = nextLayout - for (const step of lcaPath) { - if (!node || !isParent(node)) return null - node = step === 'first' ? (node as MosaicParent).first : (node as MosaicParent).second - } - return node - })() - if ( - lcaNode && - isParent(lcaNode) && - ((lcaNode as MosaicParent).first === editorKey || (lcaNode as MosaicParent).second === editorKey) && - ((lcaNode as MosaicParent).first === previewKey || (lcaNode as MosaicParent).second === previewKey) - ) { - nextLayout = updateParentSplitPercentage(nextLayout, lcaPath, 50) - } - } - } - - return sanitizeState({ layout: nextLayout, tiles: nextTiles }, id) - }) - }, [id, setMosaicState]) - - const focusTileElement = useCallback((tileKey: TileKey) => { - if (typeof document === 'undefined') return - if (typeof window === 'undefined') return - const requestId = ++focusRequestIdRef.current - const selectorKey = - typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ? CSS.escape(tileKey) : tileKey - const el = document.querySelector(`[data-refmd-tile-key="${selectorKey}"]`) - if (!el) return - try { - el.scrollIntoView({ block: 'nearest', inline: 'nearest' }) - } catch {} - - const focusInside = () => { - if (focusRequestIdRef.current !== requestId) return true - if (!el.isConnected) return false - const safe = sanitizeState(latestStateRef.current, id) - const leaves = getLeavesSafe(safe.layout) - if (!leaves.includes(tileKey)) return false - - const monacoInput = - el.querySelector('.monaco-editor textarea.inputarea') ?? - el.querySelector('.monaco-editor textarea') ?? - el.querySelector('textarea.inputarea') - if (monacoInput) { - try { - monacoInput.focus({ preventScroll: true } as any) - } catch { - try { - monacoInput.focus() - } catch {} - } - return true - } - - const firstInput = - el.querySelector('textarea, input, [contenteditable="true"], [tabindex="0"]') ?? null - if (firstInput) { - try { - firstInput.focus({ preventScroll: true } as any) - } catch { - try { - firstInput.focus() - } catch {} - } - return true - } - return false - } - - if (focusInside()) return - // Monaco might mount a tick later; try a few times. - let tries = 0 - const retry = () => { - if (focusRequestIdRef.current !== requestId) return - tries += 1 - if (focusInside()) return - if (tries >= 8) { - try { - el.focus({ preventScroll: true } as any) - } catch { - try { - el.focus() - } catch {} - } - return - } - window.requestAnimationFrame(retry) - } - window.requestAnimationFrame(retry) - }, [id]) - - useEffect(() => { - activeDocumentIdRef.current = activeDocumentId - }, [activeDocumentId]) - - useEffect(() => { - setActiveDocumentId(id) - activeDocumentIdRef.current = id - - const safe = sanitizeState(latestStateRef.current, id) - const existingTileKey = activeTileRef.current?.tileKey ?? null - const existingSpec = existingTileKey ? safe.tiles[existingTileKey] : undefined - if (existingTileKey && existingSpec?.documentId === id) { - activeTileRef.current = { tileKey: existingTileKey, documentId: id, mode: existingSpec.mode } - return - } - - const leaves = getLeavesSafe(safe.layout) - const candidates: Array<{ key: TileKey; spec: TileSpec }> = [] - for (const key of leaves) { - const spec = safe.tiles[key] - if (!spec) continue - if (spec.documentId !== id) continue - candidates.push({ key, spec }) - } - if (candidates.length === 0) return - - const modeRank: Record = { editor: 0, preview: 1, backlinks: 2, 'plugin-pane': 3 } - candidates.sort((a, b) => (modeRank[a.spec.mode] ?? 9) - (modeRank[b.spec.mode] ?? 9)) - const picked = candidates[0] - if (!picked) return - activeTileRef.current = { tileKey: picked.key, documentId: id, mode: picked.spec.mode } - }, [id]) - - useEffect(() => { - shareLinkTokenRef.current = shareLinkToken - }, [shareLinkToken]) - - const shareBrowseQuery = useQuery({ - queryKey: ['share-browse', shareToken], - queryFn: async () => browseShare(shareToken!), - staleTime: 5 * 60 * 1000, - enabled: Boolean(shareToken && (isShareMount || shareScopeProp === 'folder' || shareScopeProp == null)), - }) - - const inferredShareScope = useMemo(() => { - const tree = (shareBrowseQuery.data as any)?.tree - if (!Array.isArray(tree) || tree.length === 0) return undefined - const root = tree.find((n: any) => !n.parent_id) ?? tree[0] - return root?.type === 'folder' ? 'folder' : 'document' - }, [shareBrowseQuery.data]) - - const effectiveShareScope = shareScopeProp ?? inferredShareScope - const isSingleDocShare = Boolean(shareLinkToken && effectiveShareScope === 'document') - const focusedPluginLookup = useCreatedByPluginId(id, shareToken ?? null) - const splitCapablePluginDocs = useSplitCapablePluginDocs() - const focusedIsNonSplitPluginDoc = useMemo(() => { - const pluginId = focusedPluginLookup.pluginId - if (!pluginId) return false - return !splitCapablePluginDocs.has(id) - }, [focusedPluginLookup.pluginId, id, splitCapablePluginDocs]) - - const allowedSharedDocIds = useMemo | null>(() => { - if (!shareToken) return null - if (effectiveShareScope === 'document') return new Set([id]) - if (effectiveShareScope !== 'folder') return null - const tree = (shareBrowseQuery.data as any)?.tree - if (!Array.isArray(tree) || tree.length === 0) return null - return new Set(tree.filter((n: any) => n?.type === 'document').map((n: any) => String(n.id))) - }, [effectiveShareScope, id, shareBrowseQuery.data, shareToken]) - - const canAccessSharedDocument = useCallback( - (documentId: string) => { - const target = documentId.trim() - if (!target) return false - if (!shareToken) return true - if (effectiveShareScope === 'document') return target === id - if (effectiveShareScope === 'folder') { - if (!allowedSharedDocIds) return target === id - return allowedSharedDocIds.has(target) - } - // Unknown share scope: default to least privilege until scope is resolved. - return target === id - }, - [allowedSharedDocIds, effectiveShareScope, id, shareToken], - ) - - const markActiveDocument = useCallback( - (documentId: string, tileKey?: TileKey, mode?: TileMode) => { - const trimmed = documentId.trim() - if (!trimmed) return - setActiveDocumentId(trimmed) - activeDocumentIdRef.current = trimmed - if (tileKey && mode) { - const prev = activeTileRef.current?.tileKey ?? null - if (prev && prev !== tileKey) { - previousTileKeyRef.current = prev - } - activeTileRef.current = { tileKey, documentId: trimmed, mode } - } - // Keep the URL in sync with the currently focused document so share/copy works and - // "focused document" logic (ctx.id) updates across tiles. - if (trimmed === id) return - if (isSingleDocShare) return - try { - navigate({ - to: '/document/$id', - params: { id: trimmed }, - replace: true, - search: (prev: Record) => { - const next: Record = { ...(prev || {}) } - if (shareToken) next.token = shareToken - if (shareScopeProp) next.shareScope = shareScopeProp - if (isShareMount) next.shareMount = '1' - return next - }, - }) - } catch { - // ignore - } - }, - [id, isShareMount, isSingleDocShare, navigate, setActiveDocumentId, shareScopeProp, shareToken], - ) - - const swapTiles = useCallback( - (firstKey: TileKey, secondKey: TileKey) => { - if (firstKey === secondKey) return - setMosaicState((prev) => { - const safe = sanitizeState(prev, id) - const firstSpec = safe.tiles[firstKey] - const secondSpec = safe.tiles[secondKey] - if (!firstSpec || !secondSpec) return safe - const nextTiles: Record = { ...safe.tiles } - nextTiles[firstKey] = secondSpec - nextTiles[secondKey] = firstSpec - return sanitizeState({ ...safe, tiles: nextTiles }, id) - }) - }, - [id], - ) - - const swapActiveTileWithLast = useCallback(() => { - const active = activeTileRef.current?.tileKey ?? null - const previous = previousTileKeyRef.current - if (!active || !previous) return - swapTiles(active, previous) - }, [swapTiles]) - - const swapActiveTileByDirection = useCallback( - (direction: SwapDirection) => { - setMosaicState((prev) => { - const safe = sanitizeState(prev, id) - const active = activeTileRef.current?.tileKey ?? null - if (!active) return safe - if (!safe.layout) return safe - const panes: BspLeafPane[] = [] - collectLeafPanes(safe.layout, getRootRect(), [] as MosaicPath, panes) - const neighbor = pickNeighborLeaf(panes, active, direction) - if (!neighbor) return safe - const a = safe.tiles[active] - const b = safe.tiles[neighbor] - if (!a || !b) return safe - const nextTiles: Record = { ...safe.tiles, [active]: b, [neighbor]: a } - return sanitizeState({ ...safe, tiles: nextTiles }, id) - }) - }, - [id], - ) - - useEffect(() => { - const currentActive = activeDocumentIdRef.current - if (currentActive === id) return - const stillExists = Object.values(mosaicState.tiles).some((tile) => tile.documentId === currentActive) - if (!stillExists) { - setActiveDocumentId(id) - activeDocumentIdRef.current = id - activeTileRef.current = null - } - }, [id, mosaicState.tiles]) - - const applyViewModeForDocument = useCallback( - (documentId: string, mode: 'editor' | 'split' | 'preview') => { - const target = documentId.trim() - if (!target) return - if (isSingleDocShare && target !== id) return - - if (!canAccessSharedDocument(target)) { - toast.info('This document is not included in the shared scope.') - return - } - - setMosaicState((prev) => { - const safe = sanitizeState(prev, id) - let nextLayout = safe.layout - let nextTiles: Record = { ...safe.tiles } - let didMutateLayout = false - - const entries = Object.entries(safe.tiles) as Array<[TileKey, TileSpec]> - const editorKeys = entries - .filter(([, spec]) => spec.documentId === target && spec.mode === 'editor') - .map(([k]) => k) - const previewKeys = entries - .filter(([, spec]) => spec.documentId === target && spec.mode === 'preview') - .map(([k]) => k) - - const removeTile = (key: TileKey) => { - delete nextTiles[key] - } - - const clearSync = (key: TileKey) => { - const spec = nextTiles[key] - if (!spec) return - if (spec.mode !== 'editor' && spec.mode !== 'preview') return - if (!spec.syncGroupId) return - nextTiles[key] = { ...spec, syncGroupId: undefined } - } - - const setSpec = (key: TileKey, spec: TileSpec) => { - nextTiles[key] = spec - } - - const addLeaf = (key: TileKey) => { - nextLayout = insertLeafWithMode(nextLayout, key, activeTileRef.current?.tileKey, insertSplitMode) - didMutateLayout = true - } - - if (mode === 'editor') { - const existingEditorKey = editorKeys[0] - const reusedFromPreviewKey = !existingEditorKey ? previewKeys[0] : undefined - const editorKey = existingEditorKey ?? reusedFromPreviewKey ?? makeTileKey() - - if (!existingEditorKey && !reusedFromPreviewKey) { - setSpec(editorKey, { mode: 'editor', documentId: target }) - addLeaf(editorKey) - } else { - setSpec(editorKey, { ...nextTiles[editorKey], mode: 'editor', documentId: target, syncGroupId: undefined }) - } - - for (const key of editorKeys) { - if (key !== editorKey) removeTile(key) - } - for (const key of previewKeys) { - if (key !== editorKey) removeTile(key) - } - clearSync(editorKey) - } else if (mode === 'preview') { - const existingPreviewKey = previewKeys[0] - const reusedFromEditorKey = !existingPreviewKey ? editorKeys[0] : undefined - const previewKey = existingPreviewKey ?? reusedFromEditorKey ?? makeTileKey() - - if (!existingPreviewKey && !reusedFromEditorKey) { - setSpec(previewKey, { mode: 'preview', documentId: target }) - addLeaf(previewKey) - } else { - setSpec(previewKey, { ...nextTiles[previewKey], mode: 'preview', documentId: target, syncGroupId: undefined }) - } - - for (const key of previewKeys) { - if (key !== previewKey) removeTile(key) - } - for (const key of editorKeys) { - if (key !== previewKey) removeTile(key) - } - clearSync(previewKey) - } else { - const groupId = makeSyncGroupId() - const active = activeTileRef.current - - const pickBaseKey = () => { - if (active && active.documentId === target) { - const spec = safe.tiles[active.tileKey] - if (spec && spec.documentId === target && (spec.mode === 'editor' || spec.mode === 'preview')) { - return active.tileKey - } - } - const first = entries.find(([, spec]) => spec.documentId === target && (spec.mode === 'editor' || spec.mode === 'preview')) - return first?.[0] - } - - const baseKey = pickBaseKey() - if (!baseKey) { - const editorKey = makeTileKey() - const previewKey = makeTileKey() - setSpec(editorKey, { mode: 'editor', documentId: target, syncGroupId: groupId }) - setSpec(previewKey, { mode: 'preview', documentId: target, syncGroupId: groupId }) - nextLayout = insertLeafWithMode(nextLayout, editorKey, activeTileRef.current?.tileKey, insertSplitMode) - nextLayout = insertLeafWithMode(nextLayout, previewKey, editorKey, insertSplitMode) - didMutateLayout = true - } else { - const baseSpec = safe.tiles[baseKey] - const baseMode: TileMode = baseSpec?.mode === 'preview' ? 'preview' : 'editor' - const oppositeMode: TileMode = baseMode === 'editor' ? 'preview' : 'editor' - const existingOppositeKey = entries.find( - ([key, spec]) => key !== baseKey && spec.documentId === target && spec.mode === oppositeMode, - )?.[0] - const oppositeKey = existingOppositeKey ?? makeTileKey() - - setSpec(baseKey, { ...nextTiles[baseKey], mode: baseMode, documentId: target, syncGroupId: groupId }) - setSpec(oppositeKey, { ...nextTiles[oppositeKey], mode: oppositeMode, documentId: target, syncGroupId: groupId }) - - if (existingOppositeKey) { - const oppositePath = findPathToTile(nextLayout, existingOppositeKey) - if (oppositePath) { - nextLayout = removeLeaf(nextLayout, existingOppositeKey) - } - } - - const basePath = findPathToTile(nextLayout, baseKey) - if (!basePath) { - nextLayout = ensureLeafInLayout(nextLayout, baseKey, undefined, insertSplitMode) - } - - const finalBasePath = findPathToTile(nextLayout, baseKey) - if (!finalBasePath) { - // Shouldn't happen, but keep existing layout and append instead of replacing the whole tree. - nextLayout = ensureLeafInLayout(nextLayout, baseKey, undefined, insertSplitMode) - nextLayout = ensureLeafInLayout(nextLayout, oppositeKey, baseKey, insertSplitMode) - } - const finalPath = findPathToTile(nextLayout, baseKey) ?? ([] as MosaicPath) - const editorKey = baseMode === 'editor' ? baseKey : oppositeKey - const previewKey = baseMode === 'preview' ? baseKey : oppositeKey - const replacement: MosaicNode = { - direction: 'row', - first: editorKey, - second: previewKey, - splitPercentage: 50, - } - nextLayout = replaceNodeAtPath(nextLayout, finalPath, replacement) - didMutateLayout = true - } - - if (insertSplitMode === 'auto') { - const grid = maybeBuildTwoDocSplitGrid(nextLayout, nextTiles) - if (grid) nextLayout = grid - } - } - - if (insertSplitMode === 'row' && didMutateLayout && nextLayout) { - nextLayout = balanceLayoutSplits(nextLayout) - expandedTileKeyRef.current = null - } - - return sanitizeState({ layout: nextLayout, tiles: nextTiles }, id) - }) - }, - [canAccessSharedDocument, id, insertSplitMode, isSingleDocShare], - ) - - useEffect(() => { - if (isSingleDocShare) return - if (!focusedIsNonSplitPluginDoc) return - const current = deriveDocumentViewMode(id, mosaicState.tiles) - if (current === 'preview') return - applyViewModeForDocument(id, 'preview') - }, [applyViewModeForDocument, focusedIsNonSplitPluginDoc, id, isSingleDocShare, mosaicState.tiles]) - - useEffect(() => { - if (!mosaicStorageKeyRef.current) return - if (typeof window === 'undefined') return - if (saveTimerRef.current != null) { - window.clearTimeout(saveTimerRef.current) - saveTimerRef.current = null - } - saveTimerRef.current = window.setTimeout(() => { - saveTimerRef.current = null - const key = mosaicStorageKeyRef.current - if (!key) return - saveState(mosaicState, key) - }, 250) - return () => { - if (saveTimerRef.current != null) { - window.clearTimeout(saveTimerRef.current) - saveTimerRef.current = null - } - } - }, [mosaicState, shareLinkToken]) - - useEffect(() => { - return () => { - if (shareLinkTokenRef.current) return - const key = mosaicStorageKeyRef.current - if (clearSavedLayoutRef.current) { - if (key) { - try { - localStorage.removeItem(key) - } catch {} - } - return - } - if (saveTimerRef.current != null) { - try { - window.clearTimeout(saveTimerRef.current) - } catch {} - saveTimerRef.current = null - } - if (key) saveState(latestStateRef.current, key) - } - }, []) - - const closeAllTilesToDashboard = useCallback(() => { - if (typeof window === 'undefined') return - clearSavedLayoutRef.current = true - if (saveTimerRef.current != null) { - try { - window.clearTimeout(saveTimerRef.current) - } catch {} - saveTimerRef.current = null - } - const key = mosaicStorageKeyRef.current - if (key) { - try { - localStorage.removeItem(key) - } catch {} - } - navigate({ to: '/dashboard', replace: true }) - }, [navigate]) - - const toggleExpandTile = useCallback( - (tileKey: TileKey) => { - if (typeof window === 'undefined') return - setMosaicState((prev) => { - const safe = sanitizeState(prev, id) - const layout = safe.layout - if (!layout) return safe - const path = findPathToTile(layout, tileKey) - if (!path) return safe - - const isExpanded = expandedTileKeyRef.current === tileKey - const percentage = isExpanded ? UNEXPAND_PERCENTAGE : EXPAND_PERCENTAGE - expandedTileKeyRef.current = isExpanded ? null : tileKey - - const nextLayout = updateTree(layout, [createExpandUpdate(path, percentage)]) - return sanitizeState({ ...safe, layout: nextLayout }, id) - }) - }, - [id], - ) - - const focusActiveTileByDirection = useCallback( - (direction: SwapDirection) => { - const safe = sanitizeState(latestStateRef.current, id) - const active = activeTileRef.current?.tileKey ?? null - const layout = safe.layout - if (!active || !layout) return - const panes: BspLeafPane[] = [] - collectLeafPanes(layout, getRootRect(), [] as MosaicPath, panes) - const neighbor = pickNeighborLeaf(panes, active, direction) - if (!neighbor) return - const spec = safe.tiles[neighbor] - if (!spec) return - markActiveDocument(spec.documentId, neighbor, spec.mode) - focusTileElement(neighbor) - }, - [focusTileElement, id, markActiveDocument], - ) - - const balanceTileSizes = useCallback(() => { - setMosaicState((prev) => { - const safe = sanitizeState(prev, id) - if (!safe.layout) return safe - const nextLayout = balanceLayoutSplits(safe.layout) - if (nextLayout === safe.layout) return safe - expandedTileKeyRef.current = null - return sanitizeState({ ...safe, layout: nextLayout }, id) - }) - }, [id]) - - const closeActiveTile = useCallback(() => { - const active = activeTileRef.current?.tileKey ?? null - if (!active) return - if (isSingleDocShare) return - - const safeNow = sanitizeState(latestStateRef.current, id) - const leaves = getLeavesSafe(safeNow.layout) - if (leaves.length <= 1) { - closeAllTilesToDashboard() - return - } - - setMosaicState((prev) => { - const safe = sanitizeState(prev, id) - if (!safe.layout) return safe - if (!safe.tiles[active]) return safe - const nextLayout = removeLeaf(safe.layout, active) - const nextTiles: Record = { ...safe.tiles } - delete nextTiles[active] - expandedTileKeyRef.current = null - return sanitizeState({ layout: nextLayout, tiles: nextTiles }, id) - }) - }, [closeAllTilesToDashboard, id, isSingleDocShare]) - - const closeOtherTiles = useCallback(() => { - const active = activeTileRef.current?.tileKey ?? null - if (!active) return - if (isSingleDocShare) return - - setMosaicState((prev) => { - const safe = sanitizeState(prev, id) - const spec = safe.tiles[active] - if (!spec) return safe - expandedTileKeyRef.current = null - return sanitizeState({ layout: active, tiles: { [active]: spec } as Record }, id) - }) - }, [id, isSingleDocShare]) - - useEffect(() => { - if (!isSingleDocShare) return - setMosaicState(defaultState(id)) - }, [id, isSingleDocShare]) - - useEffect(() => { - if (typeof window === 'undefined') return - const mode = deriveDocumentViewMode(id, mosaicState.tiles) - const prev = lastReportedViewModeRef.current - if (prev && prev.docId === id && prev.mode === mode) return - lastReportedViewModeRef.current = { docId: id, mode } - dispatchMosaicCurrentViewMode(id, mode) - }, [id, mosaicState.tiles]) - - useShortcut('tiles.split.direction.auto', () => setInsertSplitMode('auto')) - useShortcut('tiles.split.direction.row', () => setInsertSplitMode('row')) - useShortcut('tiles.split.direction.column', () => setInsertSplitMode('column')) - useShortcut('tiles.swap.left', () => swapActiveTileByDirection('left'), { preventDefault: true }) - useShortcut('tiles.swap.right', () => swapActiveTileByDirection('right'), { preventDefault: true }) - useShortcut('tiles.swap.up', () => swapActiveTileByDirection('up'), { preventDefault: true }) - useShortcut('tiles.swap.down', () => swapActiveTileByDirection('down'), { preventDefault: true }) - useShortcut('tiles.swap.last', () => swapActiveTileWithLast(), { preventDefault: true }) - useShortcut('tiles.focus.left', () => focusActiveTileByDirection('left'), { preventDefault: true }) - useShortcut('tiles.focus.right', () => focusActiveTileByDirection('right'), { preventDefault: true }) - useShortcut('tiles.focus.up', () => focusActiveTileByDirection('up'), { preventDefault: true }) - useShortcut('tiles.focus.down', () => focusActiveTileByDirection('down'), { preventDefault: true }) - useShortcut( - 'tiles.toggle.expand', - () => { - const active = activeTileRef.current?.tileKey ?? null - if (!active) return - toggleExpandTile(active) - }, - { preventDefault: true }, - ) - useShortcut('tiles.balance', () => balanceTileSizes(), { preventDefault: true }) - useShortcut('tiles.close.active', () => closeActiveTile(), { preventDefault: true }) - useShortcut('tiles.close.others', () => closeOtherTiles(), { preventDefault: true }) - useShortcut( - 'tiles.open.editor', - () => { - const target = activeDocumentIdRef.current || id - addEditorTile(target) - }, - { preventDefault: true }, - ) - useShortcut( - 'tiles.open.preview', - () => { - const target = activeDocumentIdRef.current || id - addPreviewTile(target) - }, - { preventDefault: true }, - ) - - useEffect(() => { - if (isSingleDocShare) return - // If the currently focused document (URL) is no longer present in any tile (e.g. the user closed that tile), - // do not rewrite existing tiles to show it. Instead, move focus/URL to a remaining document. - const tilesNow = Object.values(mosaicState.tiles) - const hasFocused = tilesNow.some((t) => t.documentId === id) - if (!hasFocused && tilesNow.length > 0 && lastSeenRouteDocIdRef.current === id) { - const fallback = tilesNow[0]?.documentId?.trim() - if (fallback && fallback !== id) { - markActiveDocument(fallback) - return - } - } - lastSeenRouteDocIdRef.current = id - setMosaicState((prev) => { - const safe = sanitizeState(prev, id) - const tiles = Object.entries(safe.tiles) as Array<[TileKey, TileSpec]> - const hasAny = tiles.some(([, t]) => t.documentId === id) - if (hasAny) return safe - - const editorCandidate = tiles.find(([, t]) => t.mode === 'editor') - if (editorCandidate) { - const [editorKey, editorSpec] = editorCandidate - const prevDocId = editorSpec.documentId - const previewCandidate = tiles.find(([, t]) => t.mode === 'preview' && t.documentId === prevDocId) - const nextTiles: Record = {} - for (const [key, spec] of tiles) { - if (key === editorKey) nextTiles[key] = { ...spec, documentId: id } - else if (previewCandidate && key === previewCandidate[0]) nextTiles[key] = { ...spec, documentId: id } - else nextTiles[key] = spec - } - return sanitizeState({ ...safe, tiles: nextTiles }, id) - } - - const editorKey = makeTileKey() - const nextTiles: Record = { - ...safe.tiles, - [editorKey]: { mode: 'editor', documentId: id }, - } - const nextLayoutBase: MosaicNode = insertLeafWithMode( - safe.layout, - editorKey, - activeTileRef.current?.tileKey, - insertSplitMode, - ) - const nextLayout = - insertSplitMode === 'row' && nextLayoutBase ? balanceLayoutSplits(nextLayoutBase) : nextLayoutBase - return sanitizeState({ layout: nextLayout, tiles: nextTiles }, id) - }) - }, [id, insertSplitMode, isSingleDocShare, markActiveDocument, mosaicState.tiles]) - - const addEditorTile = useCallback( - (docId: string) => { - const target = docId.trim() - if (!target) return - if (isSingleDocShare) return - if (!canAccessSharedDocument(target)) { - toast.info('This document is not included in the shared scope.') - return - } - setMosaicState((prev) => { - const safe = sanitizeState(prev, id) - const exists = Object.values(safe.tiles).some((t) => t.documentId === target && t.mode === 'editor') - if (exists) return safe - const editorKey = makeTileKey() - const nextLayoutBase = insertLeafWithMode(safe.layout, editorKey, activeTileRef.current?.tileKey, insertSplitMode) - const nextLayout = - insertSplitMode === 'row' && nextLayoutBase ? balanceLayoutSplits(nextLayoutBase) : nextLayoutBase - if (insertSplitMode === 'row') expandedTileKeyRef.current = null - const nextTiles: Record = { - ...safe.tiles, - [editorKey]: { mode: 'editor', documentId: target }, - } - return sanitizeState({ layout: nextLayout, tiles: nextTiles }, id) - }) - }, - [canAccessSharedDocument, id, insertSplitMode, isSingleDocShare], - ) - - const addPreviewTile = useCallback( - (docId: string, splitMode?: InsertSplitMode) => { - const target = docId.trim() - if (!target) return - if (isSingleDocShare) return - if (!canAccessSharedDocument(target)) { - toast.info('This document is not included in the shared scope.') - return - } - setMosaicState((prev) => { - const safe = sanitizeState(prev, id) - const exists = Object.values(safe.tiles).some((t) => t.documentId === target && t.mode === 'preview') - if (exists) return safe - const previewKey = makeTileKey() - const mode = splitMode ?? insertSplitMode - const nextLayoutBase = insertLeafWithMode(safe.layout, previewKey, activeTileRef.current?.tileKey, mode) - const nextLayout = mode === 'row' && nextLayoutBase ? balanceLayoutSplits(nextLayoutBase) : nextLayoutBase - if (mode === 'row') expandedTileKeyRef.current = null - const nextTiles: Record = { - ...safe.tiles, - [previewKey]: { mode: 'preview', documentId: target }, - } - return sanitizeState({ layout: nextLayout, tiles: nextTiles }, id) - }) - }, - [canAccessSharedDocument, id, insertSplitMode, isSingleDocShare], - ) - - const addBacklinksTile = useCallback( - (docId: string) => { - const target = docId.trim() - if (!target) return - if (isSingleDocShare) return - if (!canAccessSharedDocument(target)) { - toast.info('This document is not included in the shared scope.') - return - } - setMosaicState((prev) => { - const safe = sanitizeState(prev, id) - const exists = Object.values(safe.tiles).some((t) => t.documentId === target && t.mode === 'backlinks') - if (exists) return safe - const tileKey = makeTileKey() - const nextLayoutBase = insertLeafWithMode(safe.layout, tileKey, activeTileRef.current?.tileKey, insertSplitMode) - const nextLayout = - insertSplitMode === 'row' && nextLayoutBase ? balanceLayoutSplits(nextLayoutBase) : nextLayoutBase - if (insertSplitMode === 'row') expandedTileKeyRef.current = null - const nextTiles: Record = { - ...safe.tiles, - [tileKey]: { mode: 'backlinks', documentId: target }, - } - return sanitizeState({ layout: nextLayout, tiles: nextTiles }, id) - }) - }, - [canAccessSharedDocument, id, insertSplitMode, isSingleDocShare], - ) - - const handleDocumentEditorPaneHostChange = useCallback( - (documentId: string, host: DocumentEditorPaneHostState | null) => { - const target = documentId.trim() - if (!target) return - - setDocumentPaneHosts((prev) => { - if (!host || !host.panes.length) { - if (!prev[target]) return prev - const next = { ...prev } - delete next[target] - return next - } - if (sameDocumentEditorPaneHost(prev[target], host)) return prev - return { ...prev, [target]: host } - }) - - const activePaneKey = host?.activePaneKey ?? null - const hasActivePane = Boolean(activePaneKey && host?.panes.some((pane) => pane.key === activePaneKey)) - - setMosaicState((prev) => { - const safe = sanitizeState(prev, id) - const entries = Object.entries(safe.tiles) as Array<[TileKey, TileSpec]> - const existing = entries.find( - ([, spec]) => spec.documentId === target && spec.mode === 'plugin-pane', - ) - - if (!hasActivePane || !activePaneKey) { - if (!existing) return safe - const nextTiles = { ...safe.tiles } - let nextLayout = safe.layout - for (const [key, spec] of entries) { - if (spec.documentId !== target || spec.mode !== 'plugin-pane') continue - delete nextTiles[key] - nextLayout = removeLeaf(nextLayout, key) - } - return sanitizeState({ layout: nextLayout, tiles: nextTiles }, id) - } - - if (existing) { - const [key, spec] = existing - if (spec.mode === 'plugin-pane' && spec.pluginPaneKey === activePaneKey) return safe - return sanitizeState( - { - ...safe, - tiles: { - ...safe.tiles, - [key]: { mode: 'plugin-pane', documentId: target, pluginPaneKey: activePaneKey }, - }, - }, - id, - ) - } - - if (isSingleDocShare) return safe - if (!canAccessSharedDocument(target)) return safe - - const tileKey = makeTileKey() - const nextLayout = insertDocumentPluginPaneAtRight(safe.layout, tileKey) - const nextTiles: Record = { - ...safe.tiles, - [tileKey]: { mode: 'plugin-pane', documentId: target, pluginPaneKey: activePaneKey }, - } - expandedTileKeyRef.current = null - return sanitizeState({ layout: nextLayout, tiles: nextTiles }, id) - }) - }, - [canAccessSharedDocument, id, isSingleDocShare], - ) - - useEffect(() => { - const host = documentPaneHosts[activeDocumentId] ?? null - const panes = host?.panes ?? [] - const paneKeys = new Set(panes.map((pane) => `${pluginPaneActionPrefix}${activeDocumentId}:${pane.key}`)) - const currentActions = documentActions ?? [] - let next = currentActions.filter((action) => { - if (!action.id?.startsWith(pluginPaneActionPrefix)) return true - return paneKeys.has(action.id) - }) - - for (const pane of panes) { - const actionId = `${pluginPaneActionPrefix}${activeDocumentId}:${pane.key}` - const action = { - id: actionId, - label: pane.title, - icon: renderDocumentPaneIcon(pane.icon), - tooltip: `Open ${pane.title}`, - onSelect: () => { - dispatchOpenDocumentPluginPane(activeDocumentId, pane.key) - }, - } - const existing = next.find((item) => item.id === actionId) - if (!existing) { - next = [...next, action] - continue - } - if (existing.label !== action.label || existing.tooltip !== action.tooltip) { - next = next.map((item) => (item.id === actionId ? action : item)) - } - } - - if ( - next.length !== currentActions.length || - next.some((action, index) => action !== currentActions[index]) - ) { - setDocumentActions(next) - } - }, [activeDocumentId, documentActions, documentPaneHosts, setDocumentActions]) - - useEffect(() => { - if (isSingleDocShare) return - const handler = (event: Event) => { - const detail = (event as CustomEvent<{ documentId?: string; splitMode?: InsertSplitMode }>).detail - const documentId = typeof detail?.documentId === 'string' ? detail.documentId.trim() : '' - if (!documentId) return - addPreviewTile(documentId, detail?.splitMode) - } - window.addEventListener(OPEN_PREVIEW_TILE_EVENT, handler as EventListener) - return () => window.removeEventListener(OPEN_PREVIEW_TILE_EVENT, handler as EventListener) - }, [addPreviewTile, isSingleDocShare]) - - useEffect(() => { - if (isSingleDocShare) return - const handler = (event: Event) => { - const detail = (event as CustomEvent<{ documentId?: string }>).detail - const documentId = typeof detail?.documentId === 'string' ? detail.documentId.trim() : '' - if (!documentId) return - addEditorTile(documentId) - } - window.addEventListener(OPEN_EDITOR_TILE_EVENT, handler as EventListener) - return () => window.removeEventListener(OPEN_EDITOR_TILE_EVENT, handler as EventListener) - }, [addEditorTile, isSingleDocShare]) - - useEffect(() => { - if (isSingleDocShare) return - const handler = (event: Event) => { - const detail = (event as CustomEvent<{ documentId?: string }>).detail - const documentId = typeof detail?.documentId === 'string' ? detail.documentId.trim() : '' - if (!documentId) return - addBacklinksTile(documentId) - } - window.addEventListener(OPEN_BACKLINKS_TILE_EVENT, handler as EventListener) - return () => window.removeEventListener(OPEN_BACKLINKS_TILE_EVENT, handler as EventListener) - }, [addBacklinksTile, isSingleDocShare]) - - useEffect(() => { - if (isSingleDocShare) return - const handler = (event: Event) => { - const detail = (event as CustomEvent<{ documentId?: string; paneKey?: string }>).detail - const documentId = typeof detail?.documentId === 'string' ? detail.documentId.trim() : '' - if (!documentId) return - const host = documentPaneHosts[documentId] - if (!host || !host.panes.length) return - const requested = typeof detail?.paneKey === 'string' ? detail.paneKey : '' - const pane = host.panes.find((item) => item.key === requested) ?? host.panes[0] - if (!pane) return - host.openPane(pane.key) - } - window.addEventListener(OPEN_DOCUMENT_PLUGIN_PANE_EVENT, handler as EventListener) - return () => window.removeEventListener(OPEN_DOCUMENT_PLUGIN_PANE_EVENT, handler as EventListener) - }, [documentPaneHosts, isSingleDocShare]) - - useEffect(() => { - if (typeof window === 'undefined') return - const handler = (event: Event) => { - const detail = (event as CustomEvent<{ documentId?: string; mode?: string }>).detail - const documentId = typeof detail?.documentId === 'string' ? detail.documentId.trim() : '' - const mode = detail?.mode - if (!documentId) return - if (mode !== 'editor' && mode !== 'split' && mode !== 'preview') return - applyViewModeForDocument(documentId, mode) - } - window.addEventListener(MOSAIC_SET_VIEW_MODE_EVENT, handler as EventListener) - return () => window.removeEventListener(MOSAIC_SET_VIEW_MODE_EVENT, handler as EventListener) - }, [applyViewModeForDocument]) - - return ( - ( - - )} - /> - ) -} - -function getDocText(doc: NonNullable) { - try { - return doc.getText('content').toString() - } catch { - return '' - } -} - -function toggleTaskInDoc(doc: NonNullable, lineNumber: number, checked: boolean) { - if (!Number.isInteger(lineNumber) || lineNumber < 1) return - const ytext = doc.getText('content') - const text = ytext.toString() - let offset = 0 - let currentLine = 1 - while (currentLine < lineNumber) { - const nextNewline = text.indexOf('\n', offset) - if (nextNewline === -1) return - offset = nextNewline + 1 - currentLine += 1 - } - const nextNewline = text.indexOf('\n', offset) - const lineEnd = nextNewline === -1 ? text.length : nextNewline - const lineText = text.slice(offset, lineEnd) - const taskMatch = lineText.match(/^(\s*(?:>\s*)*(?:[-*+]|\d+[.)])\s*\[)([ xX])(\]\s*)(.*)$/) - if (!taskMatch) return - const [, prefix, currentChar, closing, rest] = taskMatch - const nextChar = checked ? 'x' : ' ' - if (currentChar === nextChar) return - const newLine = `${prefix}${nextChar}${closing}${rest}` - doc.transact(() => { - const y = doc.getText('content') - y.delete(offset, lineText.length) - y.insert(offset, newLine) - }) -} - -function useDocText(doc: DocumentPageRenderContext['doc'], override?: string) { - const [text, setText] = useState(() => (override != null ? override : doc ? getDocText(doc) : '')) - const rafRef = useRef(null) - - useEffect(() => { - if (override != null) { - setText(override) - return - } - if (!doc) { - setText('') - return - } - setText(getDocText(doc)) - const ytext = doc.getText('content') - const onUpdate = () => { - if (rafRef.current != null) return - rafRef.current = window.requestAnimationFrame(() => { - rafRef.current = null - setText(ytext.toString()) - }) - } - ytext.observe(onUpdate) - return () => { - try { - ytext.unobserve(onUpdate) - } catch {} - if (rafRef.current != null) { - window.cancelAnimationFrame(rafRef.current) - rafRef.current = null - } - } - }, [doc, override]) - - return text -} - -function useElementWidth() { - const ref = useRef(null) - const [width, setWidth] = useState(0) - - useEffect(() => { - const el = ref.current - if (!el) return - - const measure = () => { - try { - setWidth(el.getBoundingClientRect().width) - } catch {} - } - - measure() - - if (typeof window === 'undefined') return - - let ro: ResizeObserver | null = null - if ('ResizeObserver' in window) { - ro = new ResizeObserver(() => measure()) - try { - ro.observe(el) - } catch {} - } - - window.addEventListener('resize', measure) - return () => { - try { - ro?.disconnect() - } catch {} - window.removeEventListener('resize', measure) - } - }, []) - - return [ref, width] as const -} - -function useCreatedByPluginId(documentId: string, token?: string | null) { - const docId = documentId.trim() - const query = useQuery({ - queryKey: ['document-meta', docId, token ?? null], - queryFn: async () => fetchDocumentMeta(docId, token ?? undefined), - staleTime: 60_000, - enabled: Boolean(docId), - }) - - const pluginId = useMemo(() => { - const raw = (query.data as any)?.created_by_plugin - return typeof raw === 'string' && raw.trim() ? raw.trim() : '' - }, [query.data]) - - const docType = useMemo(() => { - const raw = (query.data as any)?.type - return typeof raw === 'string' && raw.trim() ? raw.trim() : '' - }, [query.data]) - - return { pluginId, docType, loading: query.isPending, error: query.isError } -} - -function useSplitCapablePluginDocs() { - const [, forceUpdate] = useState(0) - useEffect(() => { - ensureSplitCapablePluginDocListener() - const listener = () => forceUpdate((n) => n + 1) - splitCapablePluginDocSubscribers.add(listener) - return () => { - splitCapablePluginDocSubscribers.delete(listener) - } - }, []) - return splitCapablePluginDocIds -} - -function PluginDocumentTileMount({ - match, - mode, - variant = 'full', - className, -}: { - match: DocumentPluginMatch - mode: 'primary' | 'secondary' - variant?: 'full' | 'preview' - className?: string -}) { - const containerRef = useRef(null) - const disposeRef = useRef<(() => void) | null>(null) - const mountNodeKey = useMemo(() => { - const pluginId = match?.manifest?.id ? String(match.manifest.id) : 'none' - return `${pluginId}:${match.docId}:${match.route}:${match.token ?? ''}:${mode}:${variant}` - }, [match, mode, variant]) - - useEffect(() => { - const container = containerRef.current - if (!container) return - let cancelled = false - - if (disposeRef.current) { - try { - disposeRef.current() - } catch {} - disposeRef.current = null - } - - ;(async () => { - try { - const dispose = (await mountResolvedPlugin( - match, - container, - mode, - variant === 'preview' - ? { - tweakHost: (host) => { - if (!host || typeof host !== 'object') return - if (!host.ui || typeof host.ui !== 'object') host.ui = {} - ;(host.ui as any).mountSplitEditor = (target: Element, options?: any) => { - if (typeof window === 'undefined') return undefined - if (!target) return undefined - const el = target as HTMLElement - const previewDelegate = options?.preview?.delegate - const onDocumentReady = options?.document?.onReady - const nextDocId = options?.docId ?? host?.context?.docId ?? null - const nextToken = options?.token ?? host?.context?.token ?? null - if (typeof nextDocId === 'string' && nextDocId.trim()) { - try { - window.dispatchEvent( - new CustomEvent<{ docId: string }>(PLUGIN_USES_SPLIT_EDITOR_EVENT, { - detail: { docId: nextDocId.trim() }, - }), - ) - } catch { - /* noop */ - } - } - return mountSplitEditorPreviewStage(el, { - docId: nextDocId, - token: nextToken, - host, - previewDelegate, - onDocumentReady, - }) - } - }, - } - : {}, - )) as any - - if (cancelled) { - if (typeof dispose === 'function') { - try { - dispose() - } catch {} - } - return - } - disposeRef.current = typeof dispose === 'function' ? dispose : null - } catch (err) { - console.error('[plugins] failed to mount plugin in tile', err) - } - })() - return () => { - cancelled = true - try { - disposeRef.current?.() - } catch {} - disposeRef.current = null - } - }, [match, mode, mountNodeKey]) - - return ( -
-
-
- ) -} - -function DocumentMosaicBody({ - ctx, - mosaicState, - setMosaicState, - addPreviewTile, - documentPaneHosts, - onDocumentEditorPaneHostChange, - insertSplitMode, - isSingleDocShare, - onCloseAllTiles, - onActivateDocument, - onToggleExpandTile, - expandedTileKeyRef, -}: { - ctx: DocumentPageRenderContext - mosaicState: MosaicState - setMosaicState: Dispatch> - addPreviewTile: (documentId: string) => void - documentPaneHosts: Record - onDocumentEditorPaneHostChange: (documentId: string, host: DocumentEditorPaneHostState | null) => void - insertSplitMode: InsertSplitMode - isSingleDocShare: boolean - onCloseAllTiles: () => void - onActivateDocument: (documentId: string, tileKey?: TileKey, mode?: TileMode) => void - onToggleExpandTile: (tileKey: TileKey) => void - expandedTileKeyRef: { current: TileKey | null } -}) { - const setTileMode = useCallback( - (tileKey: TileKey, mode: 'editor' | 'preview') => { - setMosaicState((prev) => { - const safe = sanitizeState(prev, ctx.id) - const spec = safe.tiles[tileKey] - if (!spec) return safe - if (spec.mode !== 'editor' && spec.mode !== 'preview') return safe - const nextTiles: Record = {} - for (const [key, value] of Object.entries(safe.tiles) as Array<[TileKey, TileSpec]>) { - if (key === tileKey) nextTiles[key] = { ...value, mode, syncGroupId: undefined } - else nextTiles[key] = value - } - return { ...safe, tiles: nextTiles } - }) - }, - [ctx.id, setMosaicState], - ) - - const splitFromTile = useCallback( - (tileKey: TileKey, path: MosaicPath) => { - if (isSingleDocShare) return - const splitPath = [...path] as MosaicPath - setMosaicState((prev) => { - const safe = sanitizeState(prev, ctx.id) - const spec = safe.tiles[tileKey] - if (!spec) return safe - if (spec.mode !== 'editor' && spec.mode !== 'preview') return safe - - const opposite: TileMode = spec.mode === 'editor' ? 'preview' : 'editor' - const groupId = makeSyncGroupId() - const newKey = makeTileKey() - - const nextTiles: Record = { - ...safe.tiles, - [tileKey]: { ...spec, syncGroupId: groupId }, - [newKey]: { mode: opposite, documentId: spec.documentId, syncGroupId: groupId }, - } - - const wantsEditorLeft = spec.mode === 'preview' - const replacement: MosaicNode = { - direction: 'row', - first: wantsEditorLeft ? newKey : tileKey, - second: wantsEditorLeft ? tileKey : newKey, - splitPercentage: 50, - } - let nextLayout = replaceNodeAtPath(safe.layout, splitPath, replacement) - const nextState = sanitizeState({ layout: nextLayout, tiles: nextTiles }, ctx.id) - if (insertSplitMode === 'auto') { - const grid = maybeBuildTwoDocSplitGrid(nextState.layout, nextState.tiles) - if (!grid) return nextState - return sanitizeState({ ...nextState, layout: grid }, ctx.id) - } - if (insertSplitMode === 'row' && nextState.layout) { - expandedTileKeyRef.current = null - return sanitizeState({ ...nextState, layout: balanceLayoutSplits(nextState.layout) }, ctx.id) - } - return nextState - }) - }, - [ctx.id, expandedTileKeyRef, insertSplitMode, isSingleDocShare, setMosaicState], - ) - - return ( -
- - className="refmd-mosaic-theme" - value={mosaicState.layout} - onChange={(next) => { - expandedTileKeyRef.current = null - if (!isSingleDocShare && getLeavesSafe(next).length === 0) { - onCloseAllTiles() - return - } - setMosaicState((prev) => { - const prevSafe = sanitizeState(prev, ctx.id) - return sanitizeState({ ...prevSafe, layout: next }, ctx.id) - }) - }} - renderTile={(tileId, path) => { - const spec = mosaicState.tiles[tileId] - if (!spec) { - return ( - path={path} title="" toolbarControls={[]}> -
Missing tile state.
- - ) - } - - const docId = spec.documentId - const isFocusedDoc = docId === ctx.id - const hasPreviewTileForDoc = Object.entries(mosaicState.tiles).some( - ([key, tile]) => key !== tileId && tile.documentId === docId && tile.mode === 'preview', - ) - const hasEditorTileForDoc = Object.entries(mosaicState.tiles).some( - ([key, tile]) => key !== tileId && tile.documentId === docId && tile.mode === 'editor', - ) - - if (spec.mode === 'editor') { - return ( - splitFromTile(tileId, path)} - onToggleExpand={() => onToggleExpandTile(tileId)} - onSwitchToPreview={() => setTileMode(tileId, 'preview')} - isSingleDocShare={isSingleDocShare} - onDocumentEditorPaneHostChange={onDocumentEditorPaneHostChange} - onActivate={() => onActivateDocument(docId, tileId, 'editor')} - /> - ) - } - - if (spec.mode === 'backlinks') { - return ( - onToggleExpandTile(tileId)} - onActivate={() => onActivateDocument(docId, tileId, 'backlinks')} - /> - ) - } - - if (spec.mode === 'plugin-pane') { - return ( - onToggleExpandTile(tileId)} - onActivate={() => onActivateDocument(docId, tileId, 'plugin-pane')} - /> - ) - } - - return ( - splitFromTile(tileId, path)} - onToggleExpand={() => onToggleExpandTile(tileId)} - onSwitchToEditor={() => setTileMode(tileId, 'editor')} - isSingleDocShare={isSingleDocShare} - onActivate={() => onActivateDocument(docId, tileId, 'preview')} - /> - ) - }} - /> -
- ) -} - -function MosaicPreviewTile({ - tileKey, - path, - documentId, - syncGroupId, - isFocusedDocument, - activeCtx, - addPreviewTile, - onSplit, - onToggleExpand, - onSwitchToEditor, - isSingleDocShare, - onActivate, -}: { - tileKey: TileKey - path: MosaicPath - documentId: string - syncGroupId?: string | null - isFocusedDocument: boolean - activeCtx: DocumentPageRenderContext - addPreviewTile: (documentId: string) => void - onSplit: () => void - onToggleExpand: () => void - onSwitchToEditor: () => void - isSingleDocShare: boolean - onActivate?: () => void -}) { - const { activeWorkspaceId } = useAuthContext() - const splitCapablePluginDocs = useSplitCapablePluginDocs() - const [containerRef, containerWidth] = useElementWidth() - const forceFloatingToc = containerWidth > 0 && containerWidth < FORCE_FLOATING_TOC_MAX_WIDTH_PX - const [externalScrollToLine, setExternalScrollToLine] = useState(undefined) - - useEffect(() => { - if (!syncGroupId) { - setExternalScrollToLine(undefined) - return - } - const handler = (event: Event) => { - const detail = (event as CustomEvent).detail - if (!detail || detail.source !== 'editor') return - if (detail.groupId !== syncGroupId) return - const line = detail.line - if (!Number.isFinite(line) || (line as number) < 1) return - setExternalScrollToLine(line) - } - window.addEventListener(MOSAIC_SCROLL_SYNC_EVENT, handler as EventListener) - return () => window.removeEventListener(MOSAIC_SCROLL_SYNC_EVENT, handler as EventListener) - }, [syncGroupId]) - - const pluginHint = isFocusedDocument ? activeCtx.loaderData?.createdByPlugin ?? null : null - const pluginLookup = useCreatedByPluginId(documentId, activeCtx.shareToken ?? null) - const pluginId = - (typeof pluginHint === 'string' && pluginHint.trim() ? pluginHint.trim() : pluginLookup.pluginId) || '' - const docType = pluginLookup.docType || '' - const pluginTileMode = isFocusedDocument ? ('primary' as const) : ('secondary' as const) - - const pluginQuery = useQuery({ - queryKey: [ - 'plugin-doc-match', - documentId, - activeCtx.shareToken ?? null, - pluginId || null, - docType || null, - pluginTileMode, - activeWorkspaceId ?? null, - ], - queryFn: async () => { - const token = activeCtx.shareToken ?? null - const document = docType ? { type: docType } : undefined - if (pluginId) { - return resolvePluginForDocumentById(documentId, pluginId, token, { source: pluginTileMode, document, workspaceId: activeWorkspaceId ?? null }) - } - return resolvePluginForDocument(documentId, token, { source: pluginTileMode, document, workspaceId: activeWorkspaceId ?? null }) - }, - staleTime: 60_000, - enabled: Boolean(documentId), - }) - - const pluginMatch = (pluginQuery.data ?? null) as DocumentPluginMatch | null - const shouldMountPlugin = Boolean(pluginMatch) - const isPluginDocument = Boolean(pluginId || pluginMatch) - const pluginSupportsSplit = Boolean(isPluginDocument && splitCapablePluginDocs.has(documentId)) - - const allowSplitControls = !isSingleDocShare && (!isPluginDocument || pluginSupportsSplit) - const isMarkdownPreview = !shouldMountPlugin && !isPluginDocument - - const useLiveContent = Boolean( - isMarkdownPreview && - isFocusedDocument && - !activeCtx.showOverlay && - activeCtx.doc && - activeCtx.awareness && - !activeCtx.realtimeError, - ) - const liveContent = useDocText(useLiveContent ? activeCtx.doc : null, useLiveContent ? activeCtx.previewOverride : undefined) - const canToggleTasks = Boolean(useLiveContent && activeCtx.doc && !activeCtx.isReadOnly && !activeCtx.previewOverride) - const shareToken = activeCtx.shareToken - - const previewSession = useCollaborativeDocument(documentId, shareToken, { - enabled: isMarkdownPreview && !useLiveContent, - contributeToRealtimeContext: false, - useUrlShareTokenFallback: false, - validateShareToken: false, - loadMeta: false, - trackAwareness: false, - disablePersistence: true, - }) - const realtimeContent = useDocText(!useLiveContent ? previewSession.doc : null, undefined) - - const contentQuery = useQuery({ - queryKey: ['document-content', documentId], - queryFn: async () => fetchDocumentContent(documentId), - staleTime: 30 * 1000, - enabled: isMarkdownPreview && !useLiveContent && !shareToken, - }) - - const fetchedContent = useMemo(() => { - const data = contentQuery.data as any - if (data && typeof data === 'object' && 'content' in data) { - const raw = (data as any).content - return typeof raw === 'string' ? raw : '' - } - return '' - }, [contentQuery.data]) - - const resolvedContent = useMemo(() => { - if (useLiveContent) return liveContent - if (realtimeContent.length > 0) return realtimeContent - if (fetchedContent.length > 0) return fetchedContent - // Both sources are currently empty. Prefer REST for non-share (it represents persisted content), - // otherwise fall back to realtime content. - return shareToken ? realtimeContent : fetchedContent - }, [ - fetchedContent, - liveContent, - realtimeContent, - shareToken, - useLiveContent, - ]) - - const showError = useMemo(() => { - if (useLiveContent) return false - if (previewSession.error && !fetchedContent) return true - if (!shareToken && contentQuery.isError && !previewSession.doc) return true - return false - }, [contentQuery.isError, fetchedContent, previewSession.doc, previewSession.error, shareToken, useLiveContent]) - - const showLoading = useMemo(() => { - if (useLiveContent) return false - if (showError) return false - if (shareToken) return previewSession.status === 'connecting' && !previewSession.error - return contentQuery.isLoading && !previewSession.error - }, [ - contentQuery.isLoading, - contentQuery.isError, - previewSession.status, - previewSession.error, - shareToken, - showError, - useLiveContent, - ]) - - const toolbarControls = useMemo(() => { - if (isSingleDocShare) return [] - return [ - ...(allowSplitControls - ? [ - , - ] - : []), - ...(allowSplitControls - ? [ - , - ] - : []), - , - , - , - tileControlsToggle(), - ] - }, [allowSplitControls, isSingleDocShare, onSplit, onSwitchToEditor, onToggleExpand]) - - return ( - - path={path} - title="" - toolbarControls={toolbarControls} - > -
- {shouldMountPlugin && pluginMatch ? ( - - ) : isPluginDocument ? ( -
- Plugin is not available for this document. -
- ) : ( -
- {showError ? ( -
Failed to load preview.
- ) : showLoading ? ( -
- - Loading preview… -
- ) : ( -
- dispatchMosaicScrollSync({ groupId: syncGroupId, source: 'preview', line }) - : undefined - } - onScroll={ - syncGroupId - ? (_top, pct) => { - if (pct >= 0.999) { - dispatchMosaicScrollSync({ groupId: syncGroupId, source: 'preview', line: Number.MAX_SAFE_INTEGER }) - } - } - : undefined - } - onNavigate={(targetId) => { - const target = (targetId || '').trim() - if (!target) return - addPreviewTile(target) - }} - onToggleTask={ - canToggleTasks - ? (lineNumber, checked) => { - if (!activeCtx.doc) return - toggleTaskInDoc(activeCtx.doc, lineNumber, checked) - } - : undefined - } - taskToggleDisabled={!canToggleTasks} - /> -
- )} -
- )} -
- - ) -} - -function BacklinksTile({ - path, - tileKey, - documentId, - onToggleExpand, - onActivate, -}: { - path: MosaicPath - tileKey: TileKey - documentId: string - onToggleExpand: () => void - onActivate?: () => void -}) { - return ( - - path={path} - title="" - toolbarControls={[ - , - , - , - tileControlsToggle(), - ]} - > -
-
- -
-
- - ) -} - -function DocumentPluginPaneTile({ - path, - tileKey, - paneKey, - host, - onToggleExpand, - onActivate, -}: { - path: MosaicPath - tileKey: TileKey - paneKey: string - host: DocumentEditorPaneHostState | null - onToggleExpand: () => void - onActivate?: () => void -}) { - const activePaneKey = - host?.panes.some((pane) => pane.key === paneKey) ? paneKey : (host?.activePaneKey ?? null) - - useEffect(() => { - if (!host || !activePaneKey || host.activePaneKey === activePaneKey) return - host.openPane(activePaneKey) - }, [activePaneKey, host]) - - return ( - - path={path} - title="" - toolbarControls={[ - , - , - { - host?.closePane(activePaneKey) - }} - />, - tileControlsToggle(), - ]} - > -
-
- {host && activePaneKey ? ( - - ) : ( -
- Plugin pane is not available for this document. -
- )} -
-
- - ) -} - -function useEditorIdentity() { - const { user } = useAuthContext() - const anonIdentity = useMemo(() => { - if (user) return null - try { - const keyName = 'refmd_anon_identity' - const saved = localStorage.getItem(keyName) - if (saved) return JSON.parse(saved) as { id: string; name: string } - const rnd = Math.random().toString(36).slice(-4) - const ident = { id: `guest:${rnd}`, name: `Guest-${rnd}` } - localStorage.setItem(keyName, JSON.stringify(ident)) - return ident - } catch { - const rnd = Math.random().toString(36).slice(-4) - return { id: `guest:${rnd}`, name: `Guest-${rnd}` } - } - }, [user]) - return { - userId: (user as any)?.id || anonIdentity?.id, - userName: (user as any)?.name || anonIdentity?.name, - } -} - -function EditorTile({ - tileKey, - path, - documentId, - scrollSyncGroupId, - isFocusedDocument, - ctx, - onSplit, - onToggleExpand, - onSwitchToPreview, - isSingleDocShare, - onDocumentEditorPaneHostChange, - onActivate, -}: { - tileKey: TileKey - path: MosaicPath - documentId: string - scrollSyncGroupId?: string | null - isFocusedDocument: boolean - ctx: DocumentPageRenderContext - onSplit: () => void - onToggleExpand: () => void - onSwitchToPreview: () => void - isSingleDocShare: boolean - onDocumentEditorPaneHostChange: (documentId: string, host: DocumentEditorPaneHostState | null) => void - onActivate?: () => void -}) { - const pluginLookup = useCreatedByPluginId(documentId, ctx.shareToken ?? null) - const splitCapablePluginDocs = useSplitCapablePluginDocs() - const isPluginDocument = Boolean(pluginLookup.pluginId) - const isNonSplitPluginDoc = Boolean(isPluginDocument && !splitCapablePluginDocs.has(documentId)) - - useEffect(() => { - if (!isNonSplitPluginDoc) return - dispatchMosaicSetViewMode(documentId, 'preview') - }, [documentId, isNonSplitPluginDoc]) - - return ( - - path={path} - title="" - toolbarControls={ - isSingleDocShare - ? [] - : isNonSplitPluginDoc - ? [ - , - , - , - tileControlsToggle(), - ] - : [ - , - , - , - , - , - tileControlsToggle(), - ] - } - > -
-
- -
-
- - ) -} - -function MarkdownEditorTileBody({ - tileKey, - documentId, - scrollSyncGroupId, - isFocusedDocument, - ctx, - onDocumentEditorPaneHostChange, -}: { - tileKey: TileKey - documentId: string - scrollSyncGroupId?: string | null - isFocusedDocument: boolean - ctx: DocumentPageRenderContext - onDocumentEditorPaneHostChange: (documentId: string, host: DocumentEditorPaneHostState | null) => void -}) { - const identity = useEditorIdentity() - const handlePaneHostChange = useCallback( - (host: DocumentEditorPaneHostState | null) => { - onDocumentEditorPaneHostChange(documentId, host) - }, - [documentId, onDocumentEditorPaneHostChange], - ) - const localSession = useCollaborativeDocument(documentId, ctx.shareToken, { - contributeToRealtimeContext: false, - useUrlShareTokenFallback: false, - }) - const canUseFocusedProps = isFocusedDocument && Boolean(ctx.markdownEditorProps) - - if (ctx.showOverlay && canUseFocusedProps) { - return ( -
- -
- ) - } - - if (canUseFocusedProps) { - return ( - - ) - } - - if (localSession.doc && localSession.awareness) { - return ( - - ) - } - - return ( -
- - Loading editor… -
- ) -} diff --git a/app/src/widgets/document/DocumentPage.tsx b/app/src/widgets/document/DocumentPage.tsx index d7e459e2..16ed7da1 100644 --- a/app/src/widgets/document/DocumentPage.tsx +++ b/app/src/widgets/document/DocumentPage.tsx @@ -1,7 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' import { BookmarkPlus, Download, History } from 'lucide-react' -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { ApiError, type GitPullConflictItem, type GitPullResolution } from '@/shared/api' @@ -9,7 +9,7 @@ import { useRealtime } from '@/shared/contexts/realtime-context' import { OPEN_DOCUMENT_PLUGIN_PANE_EVENT, dispatchOpenDocumentPluginPane, -} from '@/shared/lib/mosaic-events' +} from '@/shared/lib/document-workspace-events' import type { DocumentHeaderAction } from '@/shared/types/document' import { Button } from '@/shared/ui/button' @@ -18,19 +18,22 @@ import { getPullSession } from '@/entities/git' import { createShareMount, shareMountsQuery } from '@/entities/share' import { useAuthContext } from '@/features/auth' +import { BacklinksPanel } from '@/features/document-backlinks' import { DocumentDownloadDialog, OTHER_DOWNLOAD_FORMAT_GROUPS, PRIMARY_DOWNLOAD_OPTIONS, } from '@/features/document-download' import { SnapshotHistoryDialog } from '@/features/document-snapshots' -import { EditorOverlay, MarkdownEditor, useCollaborativeDocument } from '@/features/edit-document' -import type { PreviewPaneProps } from '@/features/edit-document/ui/PreviewPane' +import { EditorOverlay, MarkdownEditor, useCollaborativeDocument, useViewContext } from '@/features/edit-document' import { setConflicts as setGlobalConflicts, readResolutions, setResolutions, clearResolutions, readSessionId, setSessionId, clearSession, readConflicts, subscribeSessionId } from '@/features/git-sync/lib/git-conflict-store' import { performPullSession } from '@/features/git-sync/lib/pull-session-manager' +import { usePluginDocumentRedirect } from '@/features/plugins' import { renderDocumentPaneIcon } from '@/features/plugins/lib/pane-icons' import type { DocumentEditorPaneHostState } from '@/features/plugins/model/useDocumentEditorPlugins' -import { PluginDocumentMount } from '@/features/plugins/ui/PluginDocumentMount' +import { useSecondaryViewer } from '@/features/secondary-viewer' + +import SecondaryViewer from '@/widgets/secondary-viewer/SecondaryViewer' export type DocumentLoaderData = { title: string @@ -45,24 +48,6 @@ export type DocumentPageProps = { loaderData?: DocumentLoaderData shareToken?: string conflictMode?: boolean - render?: (ctx: DocumentPageRenderContext) => ReactNode -} - -export type DocumentPageRenderContext = { - id: string - loaderData?: DocumentLoaderData - shareToken?: string - conflictMode: boolean - status: ReturnType['status'] - doc: ReturnType['doc'] - awareness: ReturnType['awareness'] - isReadOnly: ReturnType['isReadOnly'] - realtimeError: ReturnType['error'] - overlayLabel: string - showOverlay: boolean - markdownEditorProps: Parameters[0] | null - previewOverride: string | undefined - resolvedTitle: string } const normalizeConflictPath = (path?: string | null) => (path || '').replace(/^[./]+/, '').trim().toLowerCase() @@ -230,7 +215,7 @@ const matchConflictToDoc = ( return null } -export function DocumentPage({ id, loaderData, shareToken, conflictMode = false, render }: DocumentPageProps) { +export function DocumentPage({ id, loaderData, shareToken, conflictMode = false }: DocumentPageProps) { // This component intentionally renders a placeholder on the server. // Start from the same placeholder on the client to avoid hydration mismatches, // then switch to the interactive client UI after mount. @@ -250,7 +235,6 @@ export function DocumentPage({ id, loaderData, shareToken, conflictMode = false, loaderData={loaderData} shareToken={shareToken} conflictMode={conflictMode} - render={render} /> ) } @@ -269,17 +253,26 @@ function DocumentClient({ id, loaderData, shareToken, - render, conflictMode = false, }: DocumentPageProps) { const navigate = useNavigate() const qc = useQueryClient() const { user } = useAuthContext() - const { documentTitle: realtimeTitle, documentActions, setDocumentActions, documentPluginId } = useRealtime() - const handlesDocumentPluginPanes = !render - const pluginIdHintFromLoader = typeof loaderData?.createdByPlugin === 'string' ? loaderData.createdByPlugin.trim() : '' - const pluginIdHintFromRealtime = typeof documentPluginId === 'string' ? documentPluginId.trim() : '' - const pluginIdHint = pluginIdHintFromLoader || pluginIdHintFromRealtime + const { documentTitle: realtimeTitle, documentActions, setDocumentActions } = useRealtime() + const { showBacklinks, setShowBacklinks } = useViewContext() + const { + secondaryDocumentId, + secondaryDocumentType, + showSecondaryViewer, + closeSecondaryViewer, + openSecondaryViewer, + } = useSecondaryViewer() + const pluginRedirectEnabled = + loaderData?.createdByPlugin === undefined ? true : Boolean(loaderData?.createdByPlugin) + const { redirecting, resolving: pluginResolving } = usePluginDocumentRedirect(id, { + enabled: pluginRedirectEnabled, + navigate: useCallback((to: string) => navigate({ to }), [navigate]), + }) const [showSnapshots, setShowSnapshots] = useState(false) const openSnapshots = useCallback(() => setShowSnapshots(true), []) const [showDownloadDialog, setShowDownloadDialog] = useState(false) @@ -329,7 +322,6 @@ function DocumentClient({ }, []) useEffect(() => { - if (!handlesDocumentPluginPanes) return const panes = documentPanes const paneKeys = new Set(panes.map((pane) => `${documentPluginPaneActionPrefix}${id}:${pane.key}`)) const currentActions = documentActions ?? [] @@ -365,10 +357,9 @@ function DocumentClient({ ) { setDocumentActions(next) } - }, [documentActions, documentPanes, handlesDocumentPluginPanes, id, setDocumentActions]) + }, [documentActions, documentPanes, id, setDocumentActions]) useEffect(() => { - if (!handlesDocumentPluginPanes) return const handler = (event: Event) => { const detail = (event as CustomEvent<{ documentId?: string; paneKey?: string }>).detail const documentId = typeof detail?.documentId === 'string' ? detail.documentId.trim() : '' @@ -378,11 +369,23 @@ function DocumentClient({ const requested = typeof detail?.paneKey === 'string' ? detail.paneKey : '' const pane = host.panes.find((item) => item.key === requested) ?? host.panes[0] if (!pane) return + setShowBacklinks(false) + closeSecondaryViewer() host.openPane(pane.key) } window.addEventListener(OPEN_DOCUMENT_PLUGIN_PANE_EVENT, handler as EventListener) return () => window.removeEventListener(OPEN_DOCUMENT_PLUGIN_PANE_EVENT, handler as EventListener) - }, [handlesDocumentPluginPanes, id]) + }, [closeSecondaryViewer, id, setShowBacklinks]) + + useEffect(() => { + setShowBacklinks(false) + }, [id, setShowBacklinks]) + + useEffect(() => { + if (showBacklinks && showSecondaryViewer) { + closeSecondaryViewer() + } + }, [closeSecondaryViewer, showBacklinks, showSecondaryViewer]) const anonIdentity = useMemo(() => { if (user) return null try { @@ -654,9 +657,17 @@ function DocumentClient({ const hasCollaborativeState = Boolean(doc && awareness) - const shouldShowOverlay = Boolean(realtimeError) || !hasCollaborativeState - - const overlayLabel = realtimeError || (status === 'connecting' ? 'Connecting...' : 'Loading...') + const shouldShowOverlay = pluginResolving || redirecting || Boolean(realtimeError) || !hasCollaborativeState + + const overlayLabel = realtimeError + ? realtimeError + : pluginResolving + ? 'Preparing plugin...' + : redirecting + ? 'Opening plugin...' + : status === 'connecting' + ? 'Connecting...' + : 'Loading...' const showEditor = Boolean(doc && awareness && !realtimeError) const showOverlay = shouldShowOverlay @@ -854,73 +865,46 @@ function DocumentClient({ const previewOverrideValue = showConflictUI && !isBinaryConflict ? previewContent || oursText : undefined - const usePluginPreview = Boolean(pluginIdHint) && !conflictMode - const renderPluginPreview = useCallback( - (_props: PreviewPaneProps) => ( - - ), - [id, pluginIdHint, shareToken], - ) + const extraRight = showBacklinks ? ( + setShowBacklinks(false)} /> + ) : showSecondaryViewer && secondaryDocumentId ? ( + openSecondaryViewer(docId, type)} + className="h-full" + /> + ) : undefined const markdownEditorProps = hasEditorSession ? ({ doc: doc!, awareness: awareness!, connected: status === 'connected', - initialView: 'editor', + initialView: 'split', userId: user?.id || anonIdentity?.id, userName: user?.name || anonIdentity?.name, documentId: id, documentTitle: resolvedTitle || loaderData?.title || null, documentType: 'markdown', - readOnly: isReadOnly || Boolean(activeConflict), + readOnly: isReadOnly || redirecting || Boolean(activeConflict), conflictView, conflictHunkWidgets, conflictBadgeText, conflictControls, previewOverride: previewOverrideValue, - onDocumentEditorPaneHostChange: handlesDocumentPluginPanes ? handleDocumentPaneHostChange : undefined, - extraRight: undefined, - renderPreview: usePluginPreview ? renderPluginPreview : undefined, + onDocumentEditorPaneHostChange: handleDocumentPaneHostChange, + extraRight, } satisfies Parameters[0]) : null - const renderContext: DocumentPageRenderContext = { - id, - loaderData, - shareToken, - conflictMode, - status, - doc, - awareness, - isReadOnly, - realtimeError, - overlayLabel, - showOverlay, - markdownEditorProps, - previewOverride: previewOverrideValue, - resolvedTitle: resolvedTitle || '', - } - - const body = render ? ( - render(renderContext) - ) : ( -
- {showOverlay ? : null} - {showEditor && markdownEditorProps ? : null} -
- ) - return ( <> - {body} +
+ {showOverlay ? : null} + {showEditor && markdownEditorProps ? : null} +
- Open backlinks tile + Toggle backlinks )}
@@ -125,8 +124,6 @@ export function Header({ className, realtime, variant = 'overlay' }: HeaderProps const { editor } = useEditorContext() const { toggleSidebar } = useSidebar() const navigate = useNavigate() - const focusedDocumentIdRef = useRef(undefined) - const mosaicViewModeRef = useRef>(new Map()) const [mounted, setMounted] = useState(false) const [isCompact, setIsCompact] = useState(false) const [headerViewMode, setHeaderViewMode] = useState<'editor' | 'split' | 'preview'>(() => { @@ -162,40 +159,15 @@ export function Header({ className, realtime, variant = 'overlay' }: HeaderProps }, []) const canShare = Boolean(rt.documentId) - focusedDocumentIdRef.current = rt.documentId const iconClass = 'h-[18px] w-[18px]' useEffect(() => { setMounted(true) }, []) useEffect(() => { - if (rt.documentId) return const mode = vc.viewMode if (mode === 'editor' || mode === 'split' || mode === 'preview') { setHeaderViewMode(mode) } - }, [rt.documentId, vc.viewMode]) - - useEffect(() => { - if (!rt.documentId) return - const mode = mosaicViewModeRef.current.get(rt.documentId) - if (!mode) return - setHeaderViewMode(mode) - }, [rt.documentId]) - - useEffect(() => { - if (typeof window === 'undefined') return - const handler = (event: Event) => { - const detail = (event as CustomEvent<{ documentId?: string; mode?: string }>).detail - const documentId = typeof detail?.documentId === 'string' ? detail.documentId.trim() : '' - const mode = detail?.mode - if (!documentId) return - if (mode !== 'editor' && mode !== 'split' && mode !== 'preview') return - mosaicViewModeRef.current.set(documentId, mode) - if (focusedDocumentIdRef.current !== documentId) return - setHeaderViewMode(mode) - } - window.addEventListener(MOSAIC_CURRENT_VIEW_MODE_EVENT, handler as EventListener) - return () => window.removeEventListener(MOSAIC_CURRENT_VIEW_MODE_EVENT, handler as EventListener) - }, []) + }, [vc.viewMode]) useEffect(() => { if (typeof window === 'undefined') return const mq = window.matchMedia('(max-width: 1024px)') @@ -348,31 +320,17 @@ export function Header({ className, realtime, variant = 'overlay' }: HeaderProps const normalized = pluginViewPolicy === 'previewOnly' ? 'preview' : mode const nextMode = normalized === 'split' && isCompact ? 'preview' : normalized setHeaderViewMode(nextMode) - if (isMobile) { - vc.setViewMode(nextMode) - return - } - const focusedDocumentId = focusedDocumentIdRef.current - if (focusedDocumentId) { - dispatchMosaicSetViewMode(focusedDocumentId, nextMode) - } vc.setViewMode(nextMode) }, - [isCompact, isMobile, pluginViewPolicy, vc], + [isCompact, pluginViewPolicy, vc], ) useEffect(() => { if (!mounted) return if (pluginViewPolicy !== 'previewOnly') return setHeaderViewMode('preview') - const focusedDocumentId = rt.documentId - if (focusedDocumentId) { - dispatchMosaicSetViewMode(focusedDocumentId, 'preview') - } - if (isMobile) { - vc.setViewMode('preview') - } - }, [isMobile, mounted, pluginViewPolicy, rt.documentId, vc]) + vc.setViewMode('preview') + }, [mounted, pluginViewPolicy, vc]) const shareHandler = () => { if (!rt.documentId) return @@ -382,10 +340,12 @@ export function Header({ className, realtime, variant = 'overlay' }: HeaderProps void signOut() }, [signOut]) const handleBacklinksClick = useCallback(() => { - const focusedDocumentId = focusedDocumentIdRef.current - if (!focusedDocumentId) return - dispatchOpenBacklinksTile(focusedDocumentId) - }, []) + if (!rt.documentId) return + if (!isCompact) { + changeView('split') + } + vc.toggleBacklinks() + }, [changeView, isCompact, rt.documentId, vc]) useShortcut( 'view.mode.editor', @@ -448,8 +408,9 @@ export function Header({ className, realtime, variant = 'overlay' }: HeaderProps if (!mounted) return if (isCompact && headerViewMode === 'split') { setHeaderViewMode('preview') + vc.setViewMode('preview') } - }, [headerViewMode, isCompact, mounted]) + }, [headerViewMode, isCompact, mounted, vc]) useEffect(() => { if (!isMobile) return diff --git a/app/src/widgets/secondary-viewer/SecondaryViewer.tsx b/app/src/widgets/secondary-viewer/SecondaryViewer.tsx new file mode 100644 index 00000000..6798e92a --- /dev/null +++ b/app/src/widgets/secondary-viewer/SecondaryViewer.tsx @@ -0,0 +1,239 @@ +"use client" + +import { Loader2, X } from 'lucide-react' +import { useEffect, useRef, useState, type MutableRefObject } from 'react' + +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/ui/button' + +import { PreviewPane } from '@/features/edit-document' +import { + matchesMount, + mountResolvedPlugin, + type DocumentPluginMatch, +} from '@/features/plugins' +import { + type SecondaryViewerItemType, + useSecondaryViewerContent, +} from '@/features/secondary-viewer' + +type Props = { + documentId: string | null + documentType?: SecondaryViewerItemType + className?: string + onClose?: () => void + onDocumentChange?: (id: string, type?: SecondaryViewerItemType) => void +} + +export function SecondaryViewer({ + documentId, + documentType = 'document', + className, + onClose, + onDocumentChange, +}: Props) { + const { + content, + error, + currentType, + isInitialLoading, + pluginMatch, + setError, + } = useSecondaryViewerContent(documentId, documentType) + + const pluginContainerRef = useRef(null) + const pluginDisposeRef = useRef void)>(null) + const previousRouteRef = useRef(null) + const [pluginLoading, setPluginLoading] = useState(false) + + useEffect(() => { + let cancelled = false + let shouldRestoreRoute = false + const container = pluginContainerRef.current + + if (!pluginMatch || !documentId) { + if (pluginDisposeRef.current) { + try { + pluginDisposeRef.current() + } catch { + /* noop */ + } + pluginDisposeRef.current = null + } + cleanupPlugin(container) + setPluginLoading(false) + return + } + + if (!container) return + + container.innerHTML = '' + setPluginLoading(true) + setError(null) + + ;(async () => { + shouldRestoreRoute = ensurePluginRoute(pluginMatch, previousRouteRef) + + try { + const dispose = await mountResolvedPlugin(pluginMatch, container, 'secondary') + if (cancelled) { + if (typeof dispose === 'function') { + try { + dispose() + } catch { + /* noop */ + } + } + return + } + pluginDisposeRef.current = typeof dispose === 'function' ? dispose : null + } catch (err: any) { + if (!cancelled) { + console.error('[plugins] secondary viewer mount failed', err) + setError(err?.message || 'Failed to load plugin view') + } + } finally { + if (!cancelled) { + setPluginLoading(false) + } + } + })() + + return () => { + cancelled = true + if (shouldRestoreRoute && previousRouteRef.current != null) { + try { + window.history.replaceState({}, '', previousRouteRef.current) + } catch { + /* noop */ + } + previousRouteRef.current = null + } else if (!shouldRestoreRoute) { + previousRouteRef.current = null + } + + if (pluginDisposeRef.current) { + try { + pluginDisposeRef.current() + } catch { + /* noop */ + } + pluginDisposeRef.current = null + } + + cleanupPlugin(container) + } + }, [documentId, pluginMatch, setError]) + + if (!documentId) return null + + const loading = isInitialLoading || (currentType === 'plugin' && pluginLoading) + + return ( +
+ {onClose && ( + + )} +
+
+ {error ? ( +
{error}
+ ) : currentType === 'plugin' ? ( + <> +
+ {loading && ( +
+ +

Preparing plugin...

+
+ )} + + ) : loading ? ( +
+ +
+ ) : currentType === 'scrap' ? ( +
Scrap preview is not supported yet.
+ ) : ( +
+ onDocumentChange?.(id, 'document')} + taskToggleDisabled + /> +
+ )} +
+
+
+ ) +} + +export default SecondaryViewer + +function cleanupPlugin(container: HTMLDivElement | null) { + if (!container) return + try { + container.innerHTML = '' + } catch { + /* noop */ + } +} + +function ensurePluginRoute( + match: DocumentPluginMatch, + previousRouteRef: MutableRefObject, +) { + if (typeof window === 'undefined') return false + + const mounts = Array.isArray(match.manifest?.mounts) ? match.manifest.mounts : [] + const currentPath = (() => { + try { + return window.location.pathname + } catch { + return null + } + })() + if (!currentPath) return false + + const isOnMount = mounts.some((mount) => matchesMount(mount, currentPath)) + if (!isOnMount) return false + + let target: URL + try { + target = new URL(match.route, window.location.origin) + } catch { + return false + } + + const currentFull = (() => { + try { + return window.location.pathname + window.location.search + window.location.hash + } catch { + return null + } + })() + const targetFull = `${target.pathname}${target.search}${target.hash}` + if (!currentFull || currentFull === targetFull) return false + + if (previousRouteRef.current == null) { + previousRouteRef.current = currentFull + } + + try { + window.history.replaceState({}, '', target.toString()) + return true + } catch { + return false + } +} diff --git a/app/src/widgets/sidebar/FileTree.tsx b/app/src/widgets/sidebar/FileTree.tsx index 313bd25c..527f05e0 100644 --- a/app/src/widgets/sidebar/FileTree.tsx +++ b/app/src/widgets/sidebar/FileTree.tsx @@ -6,7 +6,6 @@ import { toast } from 'sonner' import type { GitPullConflictItem, WorkspaceMembershipResponse } from '@/shared/api' import { useShortcut } from '@/shared/hooks/use-shortcut' -import { dispatchOpenPreviewTile } from '@/shared/lib/mosaic-events' import { overlayMenuClass, overlayPanelClass } from '@/shared/lib/overlay-classes' import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/ui/button' @@ -34,6 +33,7 @@ import FileNode from '@/features/file-tree/ui/FileNode' import FolderNode from '@/features/file-tree/ui/FolderNode' import { GitSyncButton } from '@/features/git-sync' import { GIT_CONFLICT_EVENT, readConflicts, readSessionId, setConflicts as setGlobalConflicts, setSessionId, clearSession, clearResolutions } from '@/features/git-sync/lib/git-conflict-store' +import { useSecondaryViewer } from '@/features/secondary-viewer' import { ShareDialog } from '@/features/sharing' import { TEMPORARY_DOCUMENT_TTL_MS, @@ -242,6 +242,7 @@ function WorkspaceSwitcher() { function FileTreeInner() { const pathname = useRouterState({ select: (s) => s.location.pathname }) const router = useRouter() + const { openSecondaryViewer } = useSecondaryViewer() const { documents, archivedDocuments, @@ -672,11 +673,11 @@ function FileTreeInner() { if (!node || node.type !== 'file') return const targetId = node.sourceId ?? node.id - dispatchOpenPreviewTile(targetId) + openSecondaryViewer(targetId) event.preventDefault() event.stopPropagation() }, - [nodeIndexMap, selectedDocId, visibleNodes], + [nodeIndexMap, openSecondaryViewer, selectedDocId, visibleNodes], ), { preventDefault: false }, ) @@ -856,11 +857,12 @@ function FileTreeInner() { onDragOver={drag.handleDragOver} onDrop={async (e, id, type) => { await handleDrop(e, id, type, parent) }} pluginRules={fileTreeRules} + onOpenSecondaryViewer={openSecondaryViewer} gitEnabled conflict={conflict} /> ) - }, [conflictForNode, createDocument, createFolder, deleteDocument, drag, duplicateDocument, expandedFolders, fileTreeRules, handleDrop, onSelect, renameDocument, selectedDocId, setShareFolderId, toggleFolder]) + }, [conflictForNode, createDocument, createFolder, deleteDocument, drag, duplicateDocument, expandedFolders, fileTreeRules, handleDrop, onSelect, openSecondaryViewer, renameDocument, selectedDocId, setShareFolderId, toggleFolder]) const renderNestedNode = useCallback((node: DocumentNode, parentId?: string, depth = 1): React.ReactNode => { const isExpanded = expandedFolders.has(node.id) @@ -915,11 +917,12 @@ function FileTreeInner() { onDragOver={drag.handleDragOver} onDrop={async (e, id, type) => { await handleDrop(e, id, type, parentId) }} pluginRules={fileTreeRules} + onOpenSecondaryViewer={openSecondaryViewer} gitEnabled conflict={conflictForNode(node)} /> ) - }, [conflictForNode, createDocument, createFolder, deleteDocument, drag, duplicateDocument, expandedFolders, fileTreeRules, handleDrop, onSelect, renameDocument, selectedDocId, setShareFolderId, toggleFolder]) + }, [conflictForNode, createDocument, createFolder, deleteDocument, drag, duplicateDocument, expandedFolders, fileTreeRules, handleDrop, onSelect, openSecondaryViewer, renameDocument, selectedDocId, setShareFolderId, toggleFolder]) return (
diff --git a/app/src/widgets/temporary/TemporaryDocumentPage.tsx b/app/src/widgets/temporary/TemporaryDocumentPage.tsx index 4d240f8f..2008a871 100644 --- a/app/src/widgets/temporary/TemporaryDocumentPage.tsx +++ b/app/src/widgets/temporary/TemporaryDocumentPage.tsx @@ -139,7 +139,7 @@ export default function TemporaryDocumentPage({ tempId }: Props) { doc={doc} awareness={awareness} connected={false} - initialView="editor" + initialView="split" documentId={tempId} documentEditorPluginsEnabled={false} readOnly={false}