app/utils/splitView.ts
Rama main · réplica de lectura del repositorio original.
import type { History } from "history";
import { parsePath } from "history";
import { action, observable } from "mobx";
import queryString from "query-string";
import { isMobile } from "@shared/utils/browser";
import { isModKey } from "@shared/utils/keyboard";
import Desktop from "./Desktop";
/**
* Name of the query string parameter that holds the route displayed in the
* secondary pane of the split view. Keeping the value in the URL allows a
* reload to hydrate both panes.
*/
export const splitViewQueryParam = "split";
/** Identifies a pane within the split view. */
export type SplitViewPane = "primary" | "secondary";
/**
* Parses the split view route from a location search string.
*
* @param search the location search string, with or without a leading "?".
* @returns the internal path shown in the secondary pane, or undefined when
* no valid split route is present.
*/
export function getSplitPath(search: string): string | undefined {
const value = queryString.parse(search)[splitViewQueryParam];
const path = Array.isArray(value) ? value[value.length - 1] : value;
if (typeof path !== "string") {
return undefined;
}
// Only allow internal, absolute paths – never protocol-relative or full
// URLs – as the value is used to drive the router directly.
if (!path.startsWith("/") || path.startsWith("//")) {
return undefined;
}
if (!isSplittablePath(parsePath(path).pathname)) {
return undefined;
}
return path;
}
/**
* Returns a new search string with the split view route set or removed,
* preserving all other query parameters.
*
* @param search the current location search string.
* @param path the internal path to open in the secondary pane, or undefined
* to remove the split view parameter.
* @returns the updated search string, prefixed with "?" when non-empty.
*/
export function setSplitPath(search: string, path: string | undefined): string {
const params = queryString.parse(search);
if (path === undefined) {
delete params[splitViewQueryParam];
} else {
params[splitViewQueryParam] = path;
}
const stringified = queryString.stringify(params);
return stringified ? `?${stringified}` : "";
}
const nonSplitViewPrefixes = [
"/api",
"/settings",
"/s",
"/share",
"/login",
"/logout",
"/create",
"/desktop-redirect",
"/oauth",
"/auth",
"/404",
];
/**
* Whether a route can be rendered inside a split view pane. Routes such as
* settings or authentication render their own chrome and must always be
* displayed full width.
*
* @param pathname the pathname to check.
* @returns true if the route can be displayed in a split view pane.
*/
export function isSplittablePath(pathname: string): boolean {
// Require an internal, absolute pathname – full URLs and protocol-relative
// values must never be treated as splittable.
if (!pathname.startsWith("/") || pathname.startsWith("//")) {
return false;
}
if (pathname === "/") {
return false;
}
return !nonSplitViewPrefixes.some(
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
);
}
const focusedSplitPane = observable.box<SplitViewPane>("primary");
/**
* Returns the pane of the split view that currently has focus. Defaults to
* the primary pane when no split view is open. The value is observable, so
* observer components reading it re-render when focus changes.
*
* @returns the focused pane.
*/
export function getFocusedSplitPane(): SplitViewPane {
return focusedSplitPane.get();
}
/**
* Sets the pane of the split view that currently has focus. Navigation
* triggered outside of a pane, such as from the sidebar or command bar, is
* directed to the focused pane.
*
* @param pane the pane to focus.
*/
export const setFocusedSplitPane = action((pane: SplitViewPane): void => {
focusedSplitPane.set(pane);
});
let navigationSuppressed = false;
/**
* Whether split view handling of navigation is temporarily suppressed, used
* when closing the split view so that the navigation is not rewritten.
*
* @returns true if split view navigation handling is suppressed.
*/
export function isSplitViewNavigationSuppressed(): boolean {
return navigationSuppressed;
}
/**
* Runs a callback with split view handling of navigation disabled, allowing
* the exact location passed to history.push or history.replace to be used.
*
* @param callback the callback to run.
*/
export function withoutSplitViewNavigation(callback: () => void): void {
navigationSuppressed = true;
try {
callback();
} finally {
navigationSuppressed = false;
}
}
let splitModifierPressed = false;
/**
* Whether an event carries the modifier combination that opens a route in the
* secondary pane of the split view. The desktop app has no tabs of its own, so
* the modifier stands in for the browser's open-in-new-tab; in the browser the
* shift key is added so that both native behaviors remain available.
*
* @param event the keyboard or mouse event to check.
* @returns true if the split view modifier combination is held.
*/
export function isSplitViewModifierEvent(
event: KeyboardEvent | MouseEvent
): boolean {
if (!isModKey(event) || event.altKey) {
return false;
}
return Desktop.isElectron() ? !event.shiftKey : event.shiftKey;
}
/**
* Starts handling navigation that should be directed to the secondary pane of
* the split view. While active:
*
* - Clicks on internal links held with the split view modifier open the link
* in the secondary pane rather than following it.
* - Whether the modifier is held during events dispatched to the window is
* recorded, allowing code without direct access to the triggering event,
* such as the patched history methods, to check it with
* isSplitViewModifierPressed.
*
* Synthetic clicks, such as the one kbar dispatches when Enter is pressed, are
* ignored so the state of the originating keyboard event is preserved.
*
* @param history the history instance to navigate with.
* @returns a function that stops handling navigation.
*/
export function initSplitViewNavigation(history: History): () => void {
let timeout: ReturnType<typeof setTimeout> | undefined;
const record = (event: KeyboardEvent | MouseEvent) => {
splitModifierPressed = isSplitViewModifierEvent(event);
// Scope the recorded state to the dispatch of this event so that a
// navigation triggered later, while the modifier happens to still be held,
// is unaffected.
clearTimeout(timeout);
timeout = setTimeout(() => {
splitModifierPressed = false;
}, 0);
};
const handleClick = (event: MouseEvent) => {
// Clicks not produced by a pointer press carry no modifier state.
if (event.detail === 0) {
return;
}
record(event);
const path = splitViewPathFromClick(event);
if (path) {
// Also stops the click reaching the link, as react-router ignores an
// event that has had its default prevented.
event.preventDefault();
openRouteInSplit(history, path);
}
};
window.addEventListener("keydown", record, { capture: true });
window.addEventListener("click", handleClick, { capture: true });
return () => {
clearTimeout(timeout);
splitModifierPressed = false;
window.removeEventListener("keydown", record, { capture: true });
window.removeEventListener("click", handleClick, { capture: true });
};
}
/**
* Whether the split view modifier combination was held during the most recent
* tracked event, see initSplitViewNavigation. Intended to be read while that
* event is still being dispatched.
*
* @returns true if the split view modifier is held.
*/
export function isSplitViewModifierPressed(): boolean {
return splitModifierPressed;
}
/**
* Resolves the path that a click should open in the secondary pane of the
* split view.
*
* @param event the click event to inspect.
* @returns the internal path to open, or undefined when the click should be
* handled normally.
*/
function splitViewPathFromClick(event: MouseEvent): string | undefined {
if (
event.defaultPrevented ||
event.button !== 0 ||
isMobile() ||
!isSplitViewModifierEvent(event) ||
!(event.target instanceof Element)
) {
return undefined;
}
const anchor = event.target.closest("a[href]");
if (
!(anchor instanceof HTMLAnchorElement) ||
anchor.target ||
anchor.hasAttribute("download")
) {
return undefined;
}
// Editor links resolve their own destination, see useEditorClickHandlers.
if (anchor.closest(".ProseMirror")) {
return undefined;
}
if (
anchor.origin !== window.location.origin ||
!isSplittablePath(anchor.pathname)
) {
return undefined;
}
return `${anchor.pathname}${anchor.search}${anchor.hash}`;
}
/**
* Opens the given path in the secondary pane of the split view, keeping the
* current route in the primary pane, and focuses the secondary pane.
*
* @param history the history instance to navigate with.
* @param path the internal path to open in the secondary pane.
*/
export function openRouteInSplit(history: History, path: string): void {
const { location } = history;
setFocusedSplitPane("secondary");
// The location below is already the intended result, so it is pushed without
// further split view handling.
withoutSplitViewNavigation(() => {
history.push({
pathname: location.pathname,
hash: location.hash,
state: location.state,
search: setSplitPath(location.search, path),
});
});
}
/**
* Closes one pane of the split view, leaving the other pane's route as the
* single displayed route, and returns focus to the primary pane. Closing the
* primary pane promotes the secondary route to become the main route.
*
* @param history the history instance to navigate with.
* @param pane the pane to close, defaults to the secondary pane.
*/
export function closeSplitPane(
history: History,
pane: SplitViewPane = "secondary"
): void {
const { location } = history;
const splitPath = getSplitPath(location.search);
setFocusedSplitPane("primary");
withoutSplitViewNavigation(() => {
if (pane === "primary" && splitPath !== undefined) {
const target = parsePath(splitPath);
history.push({
...target,
search: setSplitPath(target.search, undefined),
});
return;
}
history.push({
pathname: location.pathname,
hash: location.hash,
state: location.state,
search: setSplitPath(location.search, undefined),
});
});
}