diff --git a/apps/web/src/components/Layout/tabs/LinkButton.tsx b/apps/web/src/components/Layout/tabs/LinkButton.tsx index 5f91e8f..4b2d354 100644 --- a/apps/web/src/components/Layout/tabs/LinkButton.tsx +++ b/apps/web/src/components/Layout/tabs/LinkButton.tsx @@ -78,6 +78,8 @@ function LinkButtonFn< state.tabs.tabList.find((t) => t.id === state.tabs.activeTabId[state.tabs.activePane]), ); const activePane = useSelector((state) => state.tabs.activePane); + const documentLinkTarget = useSelector((state) => state.ui.documentLinkTarget); + const splitTabsArePreview = useSelector((state) => state.ui.splitTabsArePreview); const primaryTabList = useSelector((state) => state.tabs.tabList.filter((t) => state.tabs.paneTabIds.primary.includes(t.id)), ); @@ -113,6 +115,10 @@ function LinkButtonFn< isShiftHeld: event.shiftKey, newTab, newSplitTab, + // LinkButton renders UI chrome (the space switcher), never document content. + isInDocument: false, + documentLinkTarget, + splitTabsArePreview, }); switch (action.type) { diff --git a/apps/web/src/components/Layout/tabs/TabSync.tsx b/apps/web/src/components/Layout/tabs/TabSync.tsx index 2eee2e9..476116e 100644 --- a/apps/web/src/components/Layout/tabs/TabSync.tsx +++ b/apps/web/src/components/Layout/tabs/TabSync.tsx @@ -7,6 +7,8 @@ import { useSelector, useActions } from '@/store'; import { useLocation, useNavigate } from '@tanstack/react-router'; import { useEffect, useRef } from 'react'; import { resolveModifier, useKeyHold } from '@tanstack/react-hotkeys'; +import { useQueryClient } from '@tanstack/react-query'; +import { getDocumentByIdQueryOptions } from '@/queries/documents'; import { matchTabLocation, findGroupTab, resolveTabAction } from './utils'; /** @@ -18,6 +20,7 @@ import { matchTabLocation, findGroupTab, resolveTabAction } from './utils'; */ export function TabSync() { const navigate = useNavigate(); + const queryClient = useQueryClient(); const { openTab, updateTab, setActiveTab } = useActions(); const tabList = useSelector((state) => state.tabs.tabList); @@ -32,6 +35,8 @@ export function TabSync() { state.tabs.tabList.filter((t) => state.tabs.paneTabIds.secondary.includes(t.id)), ); const activePane = useSelector((state) => state.tabs.activePane); + const documentLinkTarget = useSelector((state) => state.ui.documentLinkTarget); + const splitTabsArePreview = useSelector((state) => state.ui.splitTabsArePreview); const isDocumentTab = activeTab && (activeTab.pathname.startsWith('/edit/') || activeTab.pathname.startsWith('/view/')); @@ -50,6 +55,8 @@ export function TabSync() { const activeTabRef = useRef(activeTab); const isModifierHeldRef = useRef(isModifierHeld); const isShiftHeldRef = useRef(isShiftHeld); + const documentLinkTargetRef = useRef(documentLinkTarget); + const splitTabsArePreviewRef = useRef(splitTabsArePreview); useEffect(() => { primaryTabListRef.current = primaryTabList; secondaryTabListRef.current = secondaryTabList; @@ -57,17 +64,59 @@ export function TabSync() { activeTabRef.current = activeTab; isModifierHeldRef.current = isModifierHeld; isShiftHeldRef.current = isShiftHeld; - }, [primaryTabList, secondaryTabList, activePane, activeTab, isModifierHeld, isShiftHeld]); + documentLinkTargetRef.current = documentLinkTarget; + splitTabsArePreviewRef.current = splitTabsArePreview; + }, [ + primaryTabList, + secondaryTabList, + activePane, + activeTab, + isModifierHeld, + isShiftHeld, + documentLinkTarget, + splitTabsArePreview, + ]); useEffect(() => { + // In-document links address documents by id (`/view/?id=true`) so they + // survive renames. Resolve that to the handle before routing, so the tab is + // created with its canonical location: tab matching works against open + // tabs, and nothing ever looks the id up as a handle. + const resolveDocumentLocation = async ( + pathname: string, + search: Record, + ): Promise<{ pathname: string; search: Record }> => { + const isDocumentPath = pathname.startsWith('/view/') || pathname.startsWith('/edit/'); + const id = pathname.split('/').pop(); + if (!search.id || !isDocumentPath || !id) return { pathname, search }; + try { + const document = await queryClient.ensureQueryData( + getDocumentByIdQueryOptions(id, queryClient), + ); + const { id: _id, ...rest } = search; + return { pathname: pathname.replace(id, document.handle), search: rest }; + } catch { + return { pathname, search }; + } + }; + const handleLinkClick = (event: MouseEvent) => { const link = event.currentTarget as HTMLAnchorElement; - const { origin, pathname, searchParams, hash } = new URL(link.href); + const { origin, pathname: rawPathname, searchParams, hash } = new URL(link.href); if (origin !== location.origin) return; if (link.download) return; - const search = Object.fromEntries(searchParams.entries()); - const normalizedHash = hash.slice(1); + event.preventDefault(); + void resolveDocumentLocation(rawPathname, Object.fromEntries(searchParams.entries())).then( + ({ pathname, search }) => routeLink(link, pathname, search, hash.slice(1)), + ); + }; + const routeLink = ( + link: HTMLAnchorElement, + pathname: string, + search: Record, + normalizedHash: string, + ) => { const action = resolveTabAction({ pathname, search, @@ -80,10 +129,11 @@ export function TabSync() { isShiftHeld: isShiftHeldRef.current, newTab: link.dataset.newTab === 'true', newSplitTab: link.dataset.newSplitTab === 'true', + isInDocument: !!link.closest('.editor-input'), + documentLinkTarget: documentLinkTargetRef.current, + splitTabsArePreview: splitTabsArePreviewRef.current, }); - event.preventDefault(); - switch (action.type) { case 'activate': setActiveTab(action.tabId); @@ -179,6 +229,20 @@ export function TabSync() { if (pathname.startsWith('/login') || pathname.startsWith('/signup')) return; const locationMatches = activeTab && matchTabLocation(activeTab, pathname, search, hash); if (locationMatches) return; + // A tab opened from an in-document link holds `/view/?id=true`; the route + // redirects it to the handle. That redirect belongs to the active tab, so it + // is updated in place (keeping its preview state) — checked before any other + // tab with the same location, which would otherwise steal the navigation. + const isIdRedirect = + !!activeTab?.search?.id && + isDocumentTab && + queryClient.getQueryData<{ handle?: string }>( + getDocumentByIdQueryOptions(documentHandle ?? '').queryKey, + )?.handle === pathname.split('/').pop(); + if (isIdRedirect) { + updateTab(activeTab.id, { pathname, search, hash }); + return; + } const existingTab = tabList.find((t) => matchTabLocation(t, pathname, search, hash)); if (existingTab) return setActiveTab(existingTab.id); const existingGroupTab = findGroupTab(tabList, pathname); diff --git a/apps/web/src/components/Layout/tabs/resolveTabMetadata.ts b/apps/web/src/components/Layout/tabs/resolveTabMetadata.ts index afe6793..abbde14 100644 --- a/apps/web/src/components/Layout/tabs/resolveTabMetadata.ts +++ b/apps/web/src/components/Layout/tabs/resolveTabMetadata.ts @@ -4,7 +4,7 @@ */ import type { TabMetadata } from '@repo/types'; -import { getDocumentByHandleQueryOptions } from '@/queries/documents'; +import { getDocumentByHandleQueryOptions, getDocumentByIdQueryOptions } from '@/queries/documents'; import type { UseQueryOptions } from '@tanstack/react-query'; export type TabMetadataQueryOption = UseQueryOptions; @@ -74,7 +74,12 @@ export function resolveTabMetadata( // 3. Document routes (dynamic — title & icon come from a query) const handle = getDocumentHandle(pathname); if (handle) { - const docQueryOpts = getDocumentByHandleQueryOptions(handle); + // In-document links carry the document id (`?id=true`) and are redirected + // to the handle by the route; looking the id up as a handle 404s, and a + // failed lookup auto-closes the tab. + const docQueryOpts = search?.id + ? getDocumentByIdQueryOptions(handle) + : getDocumentByHandleQueryOptions(handle); return { metadata: { title: '', icon: null }, // fallback while loading queryOption: { diff --git a/apps/web/src/components/Layout/tabs/utils.ts b/apps/web/src/components/Layout/tabs/utils.ts index 154cc43..0b9451b 100644 --- a/apps/web/src/components/Layout/tabs/utils.ts +++ b/apps/web/src/components/Layout/tabs/utils.ts @@ -4,6 +4,7 @@ */ import type { Tab } from '@repo/types'; +import type { DocumentLinkTarget } from '@/store/ui-slice'; export const matchTabLocation = ( tab: Tab, @@ -113,6 +114,10 @@ export interface ResolveTabActionInput { isShiftHeld: boolean; newTab: boolean; newSplitTab: boolean; + /** True when the clicked link lives inside document content (`.editor-input`). */ + isInDocument: boolean; + documentLinkTarget: DocumentLinkTarget; + splitTabsArePreview: boolean; } export type TabAction = @@ -126,7 +131,7 @@ export type TabAction = } | { type: 'preview'; - pane: 'primary' | 'secondary'; + pane: 'primary' | 'secondary' | 'opposite'; pathname: string; search: Record; hash: string; @@ -162,6 +167,11 @@ export type TabAction = * - The exact-match (`existingTab`) branch is only gated on `!shouldOpenNewTab`, * allowing split navigation to activate an existing tab in the target pane * rather than creating a duplicate. + * - When `documentLinkTarget` is 'split-view', Shift inverts for links inside + * document content: a plain click splits and Shift+Click stays in the current + * pane. An explicit `data-new-split-tab` still forces a split either way. + * - Split opens are preview-eligible when `splitTabsArePreview` is set; the + * store scopes preview replacement per pane, so each pane keeps its own. */ export const resolveTabAction = ({ pathname, @@ -175,9 +185,17 @@ export const resolveTabAction = ({ isShiftHeld, newTab, newSplitTab, + isInDocument, + documentLinkTarget, + splitTabsArePreview, }: ResolveTabActionInput): TabAction => { + // Two different intents: `newTab` (the editor marks every in-document link + // with data-new-tab) only means "never replace the document being read"; + // Ctrl/Cmd means "a permanent new tab". Both block in-place navigation, only + // the modifier blocks preview and split-by-default. const shouldOpenNewTab = isModifierHeld || newTab; - const shouldSplitTab = isShiftHeld || newSplitTab; + const splitByDefault = documentLinkTarget === 'split-view' && isInDocument && !isModifierHeld; + const shouldSplitTab = (splitByDefault ? !isShiftHeld : isShiftHeld) || newSplitTab; const activePaneTabList = activePane === 'secondary' ? secondaryTabList : primaryTabList; const oppositePaneTabList = activePane === 'secondary' ? primaryTabList : secondaryTabList; @@ -185,7 +203,10 @@ export const resolveTabAction = ({ const targetTabList = shouldSplitTab ? oppositePaneTabList : activePaneTabList; const isViewLink = pathname.startsWith('/view/'); - const isPreviewEligible = isViewLink && !shouldOpenNewTab && !shouldSplitTab; + // A same-pane open of a data-new-tab link stays a permanent tab, as before; + // a split open is preview when the preference says so. + const isPreviewEligible = + isViewLink && !isModifierHeld && (shouldSplitTab ? splitTabsArePreview : !newTab); const existingTab = targetTabList.find((t) => matchTabLocation(t, pathname, search, hash)) ?? null; @@ -198,16 +219,23 @@ export const resolveTabAction = ({ : null; // Honor new-tab/new-split-tab requests across group and same-path reuse branches. - // Exact-match tabs may still be activated when splitting — only forced new-tab (Ctrl/newTab) - // blocks that, because the existing tab is already in the target pane. + // An exact-match tab in the target pane is activated unless Ctrl/Cmd forces a + // new tab: a data-new-tab link only forbids replacing the reader's tab, and + // activating the target's own tab does not — opening another would duplicate it. if (existingGroupTab && !shouldOpenNewTab && !shouldSplitTab) { return { type: 'activate-and-update', tabId: existingGroupTab.id, pathname, search, hash }; } else if (existingTabSamePath && !shouldOpenNewTab && !shouldSplitTab) { return { type: 'activate-and-update', tabId: existingTabSamePath.id, pathname, search, hash }; - } else if (existingTab && !shouldOpenNewTab) { + } else if (existingTab && !isModifierHeld) { return { type: 'activate', tabId: existingTab.id }; } else if (isPreviewEligible) { - return { type: 'preview', pane: activePane, pathname, search, hash }; + return { + type: 'preview', + pane: shouldSplitTab ? 'opposite' : activePane, + pathname, + search, + hash, + }; } else if (!(shouldOpenNewTab || shouldSplitTab) && activeTab) { const isDocumentLink = pathname.startsWith('/edit/') || pathname.startsWith('/view/'); const requiresAutosave = diff --git a/apps/web/src/components/Settings/Preferences/InterfacePreferencesSettings.tsx b/apps/web/src/components/Settings/Preferences/InterfacePreferencesSettings.tsx index 0040b63..0c76fa9 100644 --- a/apps/web/src/components/Settings/Preferences/InterfacePreferencesSettings.tsx +++ b/apps/web/src/components/Settings/Preferences/InterfacePreferencesSettings.tsx @@ -21,11 +21,14 @@ import { THEME_BY_VALUE } from '@repo/ui/theme/themes'; import { cn } from '@repo/ui/lib/utils'; import { CircleOff, + Columns2, FoldHorizontal, Palette, RefreshCcw, + Square, UnfoldHorizontal, } from '@repo/ui/components/icons'; +import type { DocumentLinkTarget } from '@/store/ui-slice'; function InterfacePreferencesSettings() { const appSidebar = useSelector((state) => state.ui.appSidebar); @@ -33,9 +36,13 @@ function InterfacePreferencesSettings() { const folderColorsEnabled = useSelector((state) => state.ui.folderColorsEnabled); const folderDefaultColor = useSelector((state) => state.ui.folderDefaultColor); const folderColorSolid = useSelector((state) => state.ui.folderColorSolid); + const documentLinkTarget = useSelector((state) => state.ui.documentLinkTarget); + const splitTabsArePreview = useSelector((state) => state.ui.splitTabsArePreview); const { setAppSidebar, setDocumentSidebar, + setDocumentLinkTarget, + setSplitTabsArePreview, setFolderColorsEnabled, setFolderDefaultColor, setFolderColorSolid, @@ -57,7 +64,7 @@ function InterfacePreferencesSettings() { defaultValue={appSidebar} onValueChange={(value) => setAppSidebar(value as 'expanded' | 'collapsed' | 'remember')} > - + @@ -90,7 +97,7 @@ function InterfacePreferencesSettings() { setDocumentSidebar(value as 'expanded' | 'collapsed' | 'remember') } > - + @@ -194,6 +201,50 @@ function InterfacePreferencesSettings() { )} + + + +
+
+ +

+ Where a link in a document opens; Shift+Click does the opposite +

+
+ +
+ +
+
+ +

+ Preview tabs show in italics and are replaced by the next one — double-click a tab to + keep it +

+
+ +
); diff --git a/apps/web/src/store/store.ts b/apps/web/src/store/store.ts index 7366b95..9c69979 100644 --- a/apps/web/src/store/store.ts +++ b/apps/web/src/store/store.ts @@ -25,7 +25,16 @@ export const store = createStore()( }), { name: 'Wordy', - version: 4, + version: 5, + migrate: (persistedState, version) => { + const state = (persistedState ?? {}) as Pick; + // v5: the split-view default changed before the feature shipped, so + // the only persisted 'current-pane' values come from pre-release builds. + if (version < 5 && state.ui) { + return { ...state, ui: { ...state.ui, documentLinkTarget: 'split-view' as const } }; + } + return state; + }, partialize: (state) => ({ app: state.app, tabs: { @@ -37,6 +46,21 @@ export const store = createStore()( ui: state.ui, wordy: state.wordy, }), + // Zustand's default merge is shallow: a persisted slice replaces the + // whole slice, so keys added to a slice's initial state later would be + // undefined for anyone with existing storage. Merge per slice instead, + // so new preferences pick up their defaults without a version bump. + merge: (persistedState, currentState) => { + const persisted = (persistedState ?? {}) as Partial; + return { + ...currentState, + ...persisted, + app: { ...currentState.app, ...persisted.app }, + tabs: { ...currentState.tabs, ...persisted.tabs }, + ui: { ...currentState.ui, ...persisted.ui }, + wordy: { ...currentState.wordy, ...persisted.wordy }, + }; + }, }, ), { name: 'Wordy' }, diff --git a/apps/web/src/store/ui-slice.ts b/apps/web/src/store/ui-slice.ts index e8acc78..fe870fc 100644 --- a/apps/web/src/store/ui-slice.ts +++ b/apps/web/src/store/ui-slice.ts @@ -10,6 +10,8 @@ import type { Store } from './store'; export type AppSidebarState = 'expanded' | 'collapsed' | 'remember'; +export type DocumentLinkTarget = 'current-pane' | 'split-view'; + export type FolderColor = 'theme' | Theme['color-variants'][number]['value']; export type HomeSortState = { @@ -30,6 +32,8 @@ type UiState = { folderColorsEnabled: boolean; folderDefaultColor: FolderColor; folderColorSolid: boolean; + documentLinkTarget: DocumentLinkTarget; + splitTabsArePreview: boolean; }; type UiActions = { @@ -44,6 +48,8 @@ type UiActions = { setFolderColorsEnabled: (enabled: boolean) => void; setFolderDefaultColor: (color: FolderColor) => void; setFolderColorSolid: (solid: boolean) => void; + setDocumentLinkTarget: (target: DocumentLinkTarget) => void; + setSplitTabsArePreview: (preview: boolean) => void; }; export type UiSlice = { ui: UiState; uiActions: UiActions }; @@ -64,6 +70,8 @@ const initialState: UiState = { folderColorsEnabled: false, folderDefaultColor: 'theme', folderColorSolid: false, + documentLinkTarget: 'split-view', + splitTabsArePreview: true, }; export const createUiSlice: StateCreator< @@ -101,6 +109,10 @@ export const createUiSlice: StateCreator< set((state) => ({ ui: { ...state.ui, folderDefaultColor } })), setFolderColorSolid: (folderColorSolid) => set((state) => ({ ui: { ...state.ui, folderColorSolid } })), + setDocumentLinkTarget: (documentLinkTarget) => + set((state) => ({ ui: { ...state.ui, documentLinkTarget } })), + setSplitTabsArePreview: (splitTabsArePreview) => + set((state) => ({ ui: { ...state.ui, splitTabsArePreview } })), }, }; };