feat: split view#15
Conversation
- Introduced `hasUrlInDataTransfer` utility function to streamline URL detection across `PaneContent`, `useUrlDropOnPaneContent`, and `useUrlDropOnTabBar` components. - Updated drag event handlers to utilize the new utility for improved readability and maintainability.
There was a problem hiding this comment.
Pull request overview
This PR introduces a VS Code–style split-pane tabbed layout for the web app, plus a first-class attachment preview route/component, and refactors related UI/state management to support pane-aware navigation and consistent link behaviors.
Changes:
- Added split-pane tab system (DnD, URL sync, per-pane routing) and migrated app state to a vanilla Zustand store with slices.
- Added an attachment preview route (
/attachment) and viewer component; updated editor attachment actions to support “View” in split tabs. - Introduced a container-query hook + matchContainer polyfill and applied container-query breakpoints across editor/home/settings UI.
Reviewed changes
Copilot reviewed 134 out of 135 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/ui/src/lib/match-container.ts | Adds Element.matchContainer polyfill for JS container queries |
| packages/ui/src/hooks/use-container-query.ts | Hook wrapping matchContainer/ResizeObserver for container width matching |
| packages/ui/src/components/scroll-area.tsx | Adds horizontal/both orientation + exposes viewport ref |
| packages/ui/src/components/resizable.tsx | New resizable panel wrappers for split panes |
| packages/ui/src/components/portal.tsx | Allows null portal container |
| packages/ui/package.json | Adds react-resizable-panels dependency |
| packages/types/src/tabs.ts | Introduces TabMetadata; removes other tab-state interfaces from this module |
| packages/types/src/index.ts | Stops exporting database.types from package index |
| packages/types/src/database.types.ts | Removes generated database types file |
| packages/shared/src/uuid.ts | Adds UUID v4 validator helper |
| packages/shared/src/keyboard.ts | Adds modifier-mask matching helpers |
| packages/shared/src/environment.ts | Expands environment/browser capability detection constants |
| packages/editor/src/theme.css | Updates paged selectors to target editor root element dataset |
| packages/editor/src/plugins/ToolbarPlugin/index.tsx | Adjusts toolbar positioning + container query breakpoints |
| packages/editor/src/plugins/ToolbarPlugin/NodeTools.tsx | Updates responsive classes to container query variants |
| packages/editor/src/plugins/PaginationPlugin/index.tsx | Moves paged CSS vars to rootElement; adds print-time root var sync; fixes stray nodes handling |
| packages/editor/src/plugins/LinkPlugin/index.tsx | Marks internal links for “new tab” behavior (data attribute) |
| packages/editor/src/plugins/FloatingToolbar/index.tsx | Updates responsive class to container query variant |
| packages/editor/src/plugins/ComponentPickerPlugin/index.tsx | Removes non-null assertions from table option parsing |
| packages/editor/src/nodes/StickyNode/index.tsx | Simplifies style assignment (no empty-string fallback) |
| packages/editor/src/nodes/PageNode/PageNode.ts | Reads page height from editor root element instead of document root |
| packages/editor/src/nodes/PageNode/PageContentNode.ts | Minor formatting change |
| packages/editor/src/nodes/AttachmentNode/AttachmentComponent.tsx | Adds “View” action + custom download logic |
| packages/editor/src/indexeddb/provider.ts | Returns parsed SerializedEditorState instead of string |
| packages/editor/src/components/Tools/TextFormatToggles.tsx | Updates responsive classes to container query variants |
| packages/editor/src/components/Tools/AttachmentTools.tsx | Adds “View” action + custom download logic |
| packages/editor/src/components/Menus/FontSizeSelect.tsx | Updates responsive classes to container query variants |
| packages/editor/src/components/Menus/FontSelect.tsx | Updates responsive classes to container query variants |
| packages/editor/src/components/Menus/BlockFormatSelect.tsx | Updates responsive class to container query variant |
| packages/editor/src/components/Dialogs/LinkDialog.tsx | Improves decode handling + dropdown UX text/value wiring |
| packages/editor/src/components/AttachmentCard.tsx | Adds “View” button + custom download logic |
| packages/editor/src/Viewer.tsx | Adds optional documentId for checksum events; container query classes |
| packages/editor/src/Editor.tsx | Adds optional documentId for checksum events; container query classes |
| apps/web/src/store/wordy-slice.ts | New “wordy” Zustand slice (spaces/docs/clipboard/activeSpace) |
| apps/web/src/store/user-slice.ts | New user slice + user image/editor settings state |
| apps/web/src/store/ui-slice.ts | New UI slice (sidebar state, home sorts, etc.) |
| apps/web/src/store/store.ts | New vanilla Zustand store with persist/migration |
| apps/web/src/store/service.ts | Removes legacy error handler module |
| apps/web/src/store/provider.tsx | Removes legacy StoreProvider/context wrapper |
| apps/web/src/store/index.ts | Re-exports store API (no provider) |
| apps/web/src/store/hooks.tsx | Removes legacy context-based hooks |
| apps/web/src/store/hooks.ts | Adds store-bound hooks and merged useActions |
| apps/web/src/routes/_authed/view/$handle.tsx | Makes active space pane-aware using route context |
| apps/web/src/routes/_authed/spaces/manage.tsx | Layout container height tweak |
| apps/web/src/routes/_authed/spaces/index.tsx | Redirects to manage route via beforeLoad (non-preload) |
| apps/web/src/routes/_authed/spaces/favorites.tsx | Layout container height tweak |
| apps/web/src/routes/_authed/settings/index.tsx | Redirects to profile via beforeLoad (non-preload) |
| apps/web/src/routes/_authed/index.tsx | Simplifies route component to HomePage |
| apps/web/src/routes/_authed/edit/$handle.tsx | Makes active space pane-aware using route context |
| apps/web/src/routes/_authed/docs/recent-viewed.tsx | Layout container height tweak |
| apps/web/src/routes/_authed/docs/manage.tsx | Makes active space pane-aware using route context |
| apps/web/src/routes/_authed/docs/index.tsx | Redirects to manage route via beforeLoad (non-preload) |
| apps/web/src/routes/_authed/docs/favorites.tsx | Layout container height tweak |
| apps/web/src/routes/_authed/attachment.tsx | Adds attachment preview route (search-validated) |
| apps/web/src/routes/_authed.tsx | Removes embedded layout; adds loaders/sync; pane-aware activeSpace selection |
| apps/web/src/routes/__root.tsx | New root layout: sidebar + resizable split panes + per-pane routers + tab sync |
| apps/web/src/routeTree.gen.ts | Updates generated route tree for new route(s) and path shapes |
| apps/web/src/queries/revisions.ts | Adjusts local revision content handling to stringify editor state |
| apps/web/src/queries/documents.ts | Updates tab list accessors after tabs refactor |
| apps/web/src/providers/RealtimeProvider.tsx | Makes active space pane-aware using route context |
| apps/web/src/providers/AppSidebarProvider.tsx | Adds “remember” behavior via persisted appSidebarOpen |
| apps/web/src/main.tsx | Removes StoreProvider wrapper |
| apps/web/src/components/spaces/manage/Topbar.tsx | Topbar styling consistency changes |
| apps/web/src/components/spaces/manage/TableContent.tsx | Type cleanup + minor tree handling adjustments |
| apps/web/src/components/spaces/FavoriteSpacesTopbar.tsx | Topbar styling consistency + class fixes |
| apps/web/src/components/documents/view-document.tsx | Refactors to use new DocumentSidebar wrapper + passes documentId to Viewer |
| apps/web/src/components/documents/view-document-loading.tsx | Refactors loading state to use new DocumentSidebar wrapper |
| apps/web/src/components/documents/useDocumentActions.ts | Moves autosave/navigation logic to tab/checksum model |
| apps/web/src/components/documents/manage/Topbar.tsx | Topbar styling consistency changes |
| apps/web/src/components/documents/manage/TableRow.tsx | Adds data-new-tab to document link |
| apps/web/src/components/documents/manage/TableContent.tsx | Makes active space pane-aware using route context |
| apps/web/src/components/documents/manage/Table.tsx | Makes active space pane-aware using route context |
| apps/web/src/components/documents/manage/Header.tsx | Makes active space pane-aware + icon null handling |
| apps/web/src/components/documents/edit-document.tsx | Refactors sidebar usage; uses document actions + passes documentId to Editor |
| apps/web/src/components/documents/edit-document-loading.tsx | Refactors loading state to use new DocumentSidebar wrapper |
| apps/web/src/components/documents/document-sidebar/index.tsx | Major refactor: scroll areas, portal trigger, container-query desktop detection, persisted state via store |
| apps/web/src/components/documents/document-sidebar/TableOfContents/index.tsx | Removes manual scrolling classes (delegates to ScrollArea) |
| apps/web/src/components/documents/document-sidebar/RevisionsHistory/index.tsx | Removes manual scrolling classes (delegates to ScrollArea) |
| apps/web/src/components/documents/document-sidebar/RevisionsHistory/RevisionCard.tsx | Updates revision navigation to tab updates + checksum dispatch |
| apps/web/src/components/documents/document-sidebar/PageSetup/index.tsx | Removes manual scrolling classes (delegates to ScrollArea) |
| apps/web/src/components/documents/document-sidebar/Attachments/index.tsx | Removes manual scrolling classes (delegates to ScrollArea) |
| apps/web/src/components/docs/RecentViewedDocsTopbar.tsx | Topbar styling consistency + class fixes |
| apps/web/src/components/docs/FavoriteDocumentRow.tsx | Adds data-new-tab to document link |
| apps/web/src/components/docs/FavoriteDocsTopbar.tsx | Topbar styling consistency + class fixes |
| apps/web/src/components/docs/DocumentRow.tsx | Adds data-new-tab to document link |
| apps/web/src/components/Settings/Users/columns.tsx | Adds data-new-tab to settings links |
| apps/web/src/components/Settings/Users/UserForm.tsx | Updates responsive classes to container query variants |
| apps/web/src/components/Settings/UserImages/CoverImage.tsx | Updates responsive classes to container query variants |
| apps/web/src/components/Settings/SettingsNavigations.tsx | Class ordering fix |
| apps/web/src/components/Settings/SettingsNav.tsx | Updates responsive classes to container query variants |
| apps/web/src/components/Settings/Roles/columns.tsx | Adds data-new-tab to role link |
| apps/web/src/components/Settings/Preferences/InterfacePreferencesSettings.tsx | Splits app vs document sidebar preferences + store-backed persistence |
| apps/web/src/components/Layout/tabs/utils.ts | Adds helpers for tab location matching + external URL DnD |
| apps/web/src/components/Layout/tabs/useUrlDropOnTabBar.ts | External URL drop support for tab bar |
| apps/web/src/components/Layout/tabs/useUrlDropOnPaneContent.ts | External URL drop support for pane content (split-aware) |
| apps/web/src/components/Layout/tabs/useTabMetadata.ts | Refactors metadata to resolved + query-driven model |
| apps/web/src/components/Layout/tabs/resolveTabMetadata.ts | Centralized metadata resolution + dynamic query options |
| apps/web/src/components/Layout/tabs/index.ts | Re-exports new tab system components |
| apps/web/src/components/Layout/tabs/TabSync.tsx | URL↔tab sync + keyboard shortcuts + link interception (split-aware) |
| apps/web/src/components/Layout/tabs/TabDropSlot.tsx | Cross-pane drop slots between tabs |
| apps/web/src/components/Layout/tabs/TabDndProvider.tsx | DnD context provider for tabs across panes |
| apps/web/src/components/Layout/tabs/TabBar.tsx | Removes legacy single-pane tab bar |
| apps/web/src/components/Layout/tabs/Tab.tsx | Updates tab rendering for panes + drop slots + metadata + dirty/save UI |
| apps/web/src/components/Layout/tabs/PaneTabBar.tsx | New per-pane tab bar (scrolling, split controls, URL drop) |
| apps/web/src/components/Layout/tabs/PaneContent.tsx | New per-pane content wrapper + split drop zones |
| apps/web/src/components/Layout/space-switcher/hooks.ts | Makes active space pane-aware + effect dependency update |
| apps/web/src/components/Layout/space-switcher/SpaceSwitcher.tsx | ScrollArea integration + data-new-tab + context menu restructuring |
| apps/web/src/components/Layout/space-switcher/SpaceItem.tsx | Makes active space pane-aware for highlighting |
| apps/web/src/components/Layout/nav-user.tsx | Prevents autofocus issues + data-new-tab on settings links |
| apps/web/src/components/Layout/nav-secondary.tsx | Adds data-new-tab to links |
| apps/web/src/components/Layout/nav-main.tsx | Adds data-new-tab to sublinks |
| apps/web/src/components/Layout/nav-documents.tsx | Makes active space pane-aware + data-new-tab on label link |
| apps/web/src/components/Layout/document-tree/useDocumentTree.ts | Derives active doc from active tab/pane instead of location |
| apps/web/src/components/Layout/document-tree/index.tsx | Wraps tree in ScrollArea + pane-aware active space |
| apps/web/src/components/Layout/document-tree/RegularDocumentItem.tsx | Adds data-new-tab behavior to doc links |
| apps/web/src/components/Layout/document-tree/NavDocumentsContextMenu.tsx | Pane-aware active space for paste operations |
| apps/web/src/components/Layout/document-tree/CreateDocumentSection.tsx | Moves “hidden” state to persisted UI slice |
| apps/web/src/components/Layout/app-sidebar.tsx | Class ordering fix |
| apps/web/src/components/Layout/app-header.tsx | Removes legacy TabBar from header; styling/link behavior updates |
| apps/web/src/components/Layout/SplitPaneRouter.tsx | Adds per-pane nested router via memory history |
| apps/web/src/components/Home/index.tsx | Moves home sorts persistence to UI slice + container-query typography |
| apps/web/src/components/Home/RecentViewedDocumentItem.tsx | Adds data-new-tab |
| apps/web/src/components/Home/HomeRecentViewsDocuments.tsx | Heading styling + data-new-tab on “View more” |
| apps/web/src/components/Home/HomeFavoriteSpaces.tsx | Heading styling + data-new-tab on “View more” |
| apps/web/src/components/Home/HomeFavoriteDocuments.tsx | Heading styling + data-new-tab on “View more” |
| apps/web/src/components/Home/HomeAllDocuments.tsx | Heading styling |
| apps/web/src/components/Home/FavoriteDocumentItem.tsx | Adds data-new-tab |
| apps/web/src/components/AttachmentViewer/index.tsx | New attachment preview component (image/video/audio/pdf) |
| apps/web/src/App.tsx | Switches to singleton store + adds split-pane context defaults |
| apps/web/src/App.css | Scroll/containment tweaks + adds scrollbar-gutter utility |
| apps/web/package.json | Adds @tanstack/react-hotkeys dependency |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| <div className="flex items-center gap-1.5"> | ||
| <Button variant="outline" className="bg-transparent" size="icon" asChild> | ||
| <a href={href} download={name} aria-label={`Download ${name}`}> | ||
| {canView ? ( | ||
| <Button | ||
| variant="outline" | ||
| className="bg-transparent" | ||
| size="icon" | ||
| disabled={!canView} | ||
| aria-label={`View ${name}`} | ||
| title="View" | ||
| asChild | ||
| > |
There was a problem hiding this comment.
When canView is true, the card renders only the View button and no longer provides any Download action in this UI. That’s a behavioral regression vs the previous always-download affordance and makes downloading viewable attachments harder/ impossible outside the editor context menu. Consider always showing Download (and optionally View) so preview doesn’t replace download functionality.
| containerQuerySheet.replaceSync(css); | ||
| document.adoptedStyleSheets = [...document.adoptedStyleSheets, containerQuerySheet]; | ||
| const style = getComputedStyle(element); |
There was a problem hiding this comment.
This polyfill appends a new CSSStyleSheet into document.adoptedStyleSheets for every matchContainer() call, but nothing ever removes these sheets. Over time (e.g., components mounting/unmounting, query re-creation) this will leak stylesheets and can degrade performance. Consider reusing a single shared stylesheet per document and/or implementing a cleanup/dispose mechanism that removes the sheet when the corresponding ContainerQueryList is no longer needed.
| const handleDownload = async () => { | ||
| const href = signedUrl ?? url; | ||
| try { | ||
| const res = await fetch(href); | ||
| const blob = await res.blob(); | ||
| const blobUrl = URL.createObjectURL(blob); | ||
| const link = document.createElement('a'); | ||
| link.href = blobUrl; | ||
| link.download = name; | ||
| document.body.appendChild(link); | ||
| link.click(); | ||
| document.body.removeChild(link); | ||
| URL.revokeObjectURL(blobUrl); | ||
| } catch { | ||
| window.open(url, '_blank'); | ||
| } | ||
| }; |
There was a problem hiding this comment.
handleDownload computes href = signedUrl ?? url, but the fallback window.open uses the unsigned url even when a signedUrl is available. This can cause downloads to fail for private attachments. Use the same resolved href for the fallback open as well.
| set((s) => { | ||
| if (!s.user) return { user: undefined }; | ||
| return { | ||
| user: { |
There was a problem hiding this comment.
If there is no user in state, this setter returns { user: undefined }, but the slice's UserState type and the rest of the code treat the absence of a user as null. Setting user to undefined can cause unexpected runtime checks to fail (e.g., strict null comparisons, JSON serialization for persist). Return the previous state/no-op, or explicitly keep user: null instead.
| useEffect(() => { | ||
| if (spaces && !isLoading) { | ||
| if (activeSpace) { | ||
| const activeSpaceFullData = spaces?.find((space) => space.id === activeSpace.id); | ||
| if (activeSpaceFullData && activeSpaceFullData.isContainer) { | ||
| const space = spaces.find((s) => Boolean(s.isContainer) == false); | ||
| if (!space) return; | ||
| const path = calculateSpacePath(space.id, spaces as Space[]); | ||
| setActiveSpace({ | ||
| ...space, | ||
| icon: space.icon || 'briefcase', | ||
| path, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| }, [activeSpace, spaces, isLoading]); | ||
| }, [activeSpace]); |
There was a problem hiding this comment.
This effect reads spaces and isLoading from the query but they are not included in the dependency array. If the spaces list changes (or loads after mount) while activeSpace stays the same, this effect may run with stale values and fail to correct an invalid "container" activeSpace. Include spaces and isLoading (and any referenced actions) in the dependencies, or merge this logic into the first effect that already depends on them.
| el.style.setProperty('transition', `${sentinelProperty} 0.001ms step-start`); | ||
| el.style.setProperty('transition-behavior', 'allow-discrete'); | ||
| const onTransitionRun = (e: TransitionEvent) => { |
There was a problem hiding this comment.
matchContainer() sets an inline "transition" style on the observed element, overwriting any existing transitions the element may already have. This can break unrelated UI animations on elements that call matchContainer(). Prefer appending to existing transition values (or setting only transition-property/transition-duration for the sentinel) and restore the previous inline styles on cleanup.
| el.addEventListener('transitionrun', onTransitionRun); | ||
|
|
||
| const computedStyle = getComputedStyle(el); | ||
| const currentValue = computedStyle.getPropertyValue(sentinelProperty); | ||
| _previousValues[sentinelProperty] = currentValue; | ||
| } |
There was a problem hiding this comment.
The matchContainer() observer adds a "transitionrun" event listener but never removes it, and the marker attribute / sentinel styles also remain on the element. Even though useContainerQuery removes its own "change" listener, the underlying transition listener will persist for the lifetime of the page. Add a way to disconnect (e.g., a dispose method on ContainerQueryList that removes the transition listener and cleans up styles/attributes) and have callers invoke it during effect cleanup.
| setCoverImage: (coverImage) => | ||
| set((s) => { | ||
| if (!s.user) return { user: undefined }; | ||
| return { | ||
| user: { | ||
| ...s.user, | ||
| cover_image: coverImage, | ||
| }, | ||
| }; | ||
| }), |
There was a problem hiding this comment.
Same issue as setAvatarImage: returning { user: undefined } when there is no user will put the store into an unexpected state. Prefer a no-op or keep user: null.
| // Created once per mount (component remounts when tab.id changes via key). | ||
| const [splitRouter] = useState(() => | ||
| createRouter({ | ||
| routeTree, | ||
| history: createMemoryHistory({ initialEntries: [buildHref(tab)] }), | ||
| context: { | ||
| store, | ||
| queryClient, | ||
| session: { data: session, isLoading: isPending || isRefetching }, | ||
| isSplitPane: true as const, | ||
| splitPaneType: type, | ||
| }, | ||
| }), |
There was a problem hiding this comment.
SplitPaneRouter creates the router once and bakes session into the router context at creation time. Unlike the main , this RouterProvider call does not pass a dynamic context prop, so session/isLoading changes won't propagate into the split-pane routes. Pass context={{ store, queryClient, session: ... , isSplitPane: true, splitPaneType: type }} to (or otherwise update the router context) so auth-gated routes behave correctly.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
| <PaneTabBar pane="primary" /> | ||
| <PaneContent pane="primary"> | ||
| {primaryActiveTab && ( | ||
| <SplitPaneRouter tab={primaryActiveTab} type="primary" /> | ||
| )} | ||
| </PaneContent> |
There was a problem hiding this comment.
SplitPaneRouter's doc comment says it relies on key={tab.id} remounting to ensure per-tab router/history isolation, but the component is mounted without a key here. Without a key, the split pane's memory history will be shared across different tabs in the same pane (back/forward behavior can become incorrect). Add key={primaryActiveTab.id} / key={secondaryActiveTab.id} when rendering SplitPaneRouter to keep per-tab history isolated.
|
@ibastawisi I've opened a new pull request, #16, to work on those changes. Once the pull request is ready, I'll request review from you. |
* Initial plan * fix: pass dynamic context to SplitPaneRouter's RouterProvider The router was created once with session baked in at creation time, so session/isLoading changes never propagated into split-pane routes. Mirror the App.tsx pattern: initialize the router with placeholder session values, then pass the live session and all other context via the `context` prop on <RouterProvider> so every render picks up the latest values. Co-authored-by: ibastawisi <13211445+ibastawisi@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ibastawisi <13211445+ibastawisi@users.noreply.github.com>
This pull request introduces several improvements and new features to the web app, with a focus on enhancing attachment viewing, split pane navigation, and UI consistency. The most notable additions are a new
AttachmentViewercomponent for rich file previews and aSplitPaneRouterfor advanced routing in split pane layouts. Additionally, UI styling and link behaviors have been standardized across home page components.New Features
AttachmentViewercomponent, allowing users to preview images, videos, audio, PDFs, and unknown file types directly in the app, with graceful error handling and download fallback. (apps/web/src/components/AttachmentViewer/index.tsx)SplitPaneRoutercomponent for mounting a secondary router instance in split pane layouts, enabling independent navigation and context for each pane. (apps/web/src/components/Layout/SplitPaneRouter.tsx)UI Consistency and Styling
apps/web/src/components/Home/HomeAllDocuments.tsx,apps/web/src/components/Home/HomeFavoriteDocuments.tsx,apps/web/src/components/Home/HomeFavoriteSpaces.tsx,apps/web/src/components/Home/HomeRecentViewsDocuments.tsx,apps/web/src/components/Home/index.tsx) [1] [2] [3] [4] [5] [6]scrollbar-gutterto a utility class and removing unused font-face declarations. (apps/web/src/App.css) [1] [2] [3] [4]Navigation and Link Behavior
data-new-tab="true"to links in home page components to consistently indicate new tab behavior for document and space links. (apps/web/src/components/Home/FavoriteDocumentItem.tsx,apps/web/src/components/Home/HomeFavoriteDocuments.tsx,apps/web/src/components/Home/HomeFavoriteSpaces.tsx,apps/web/src/components/Home/HomeRecentViewsDocuments.tsx,apps/web/src/components/Home/RecentViewedDocumentItem.tsx) [1] [2] [3] [4] [5]Dependency and Store Updates
@tanstack/react-hotkeysdependency for improved keyboard interactions and refactored store usage inApp.tsxand home page components for better state management. (apps/web/package.json,apps/web/src/App.tsx,apps/web/src/components/Home/index.tsx) [1] [2] [3] [4] [5]