feat(editor): Drag tabs and files between split panes

HTML5 drag and drop never reaches the webview while native file drop is
enabled, so tab, project-file and changelist drags did nothing. They now
run on pointer events:

- Drag an editor tab onto another pane, or onto a pane's edge to split it.
- Drag files from the Project view into an editor pane (center opens them
  there, an edge opens them in a new split).
- Drag files between changelists in the Commit view.
- "Drag-and-drop with Alt pressed only" applies to tabs and stripe buttons.
This commit is contained in:
l0ng-ai committed 2026-10-28 08:27:20 +08:00
1 parent 48ac2094fc
commit 60722e0cc5
13 files changed
+345 -118

No files matched your search

+17 -2
View File
@@ -52,6 +52,7 @@ import {
removePane,
reopenClosed,
setActivePane,
splitAtEdge,
sortTabs,
splitPane,
tabOptions,
@@ -784,7 +785,7 @@ export class EditorController implements SlotHost {
this.navSuppress++;
try {
let s = useTabs.getState();
let paneId = s.activePane;
let paneId = o.pane && s.panes[o.pane] ? o.pane : s.activePane;
if (o.side) {
const right = neighbourPane(s, paneId);
if (right) paneId = right;
@@ -799,7 +800,7 @@ export class EditorController implements SlotHost {
}
const found = findTab(s, input.key, paneId);
let tabId: string;
if (found && (!o.side || found.paneId === paneId)) {
if (found && (!(o.side || o.pane) || found.paneId === paneId)) {
paneId = found.paneId;
s = activateTab(setActivePane(s, paneId), paneId, found.tab.id);
if (!o.preview) s = promoteTab(s, paneId, found.tab.id);
@@ -935,6 +936,20 @@ export class EditorController implements SlotHost {
if (slot?.view) slot.focus();
else if (slot) slot.wantsFocus = true;
}
/** Files dropped on an editor: opened in that pane, or in a new split at `edge`. */
async openFilesIn(paths: readonly string[], paneId: string, edge: SplitEdge | "center") {
const s = useTabs.getState();
if (!s.panes[paneId] || !paths.length) return;
let target = paneId;
if (edge !== "center") {
const r = splitAtEdge(s, paneId, edge);
useTabs.setState(r.state, true);
target = r.paneId;
}
for (const [i, path] of paths.entries()) await this.openFile(path, { pane: target, focus: i === paths.length - 1 });
const pane = useTabs.getState().panes[target];
if (pane && !pane.tabs.length) this.apply((st) => removePane(st, target));
}
/** IDEA "Move to Opposite Group"; without a split, "Split and Move Right". */
moveToOpposite(paneId: string, tabId: string) {
const target = neighbourPane(useTabs.getState(), paneId);
+13 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { initialTabsState, moveTabToNewSplit, openTab, paneIds, splitPane, togglePin, type TabsState } from "./tabs";
import { initialTabsState, moveTabToNewSplit, openTab, paneIds, splitAtEdge, splitPane, togglePin, type TabsState } from "./tabs";
import { loadSession, parseSession, restoreTabsState, serializeSession, slashPath } from "./session";
const file = (path: string) => ({ kind: "file", key: `file:${path}`, path });
@@ -145,3 +145,15 @@ describe("sessions saved with Windows backslash paths", () => {
}
});
});
describe("splitAtEdge", () => {
it("adds an empty active pane on the given side", () => {
const s = open(initialTabsState(), "/a");
const pane = s.activePane;
const r = splitAtEdge(s, pane, "right");
expect(paneIds(r.state)).toEqual([pane, r.paneId]);
expect(r.state.panes[r.paneId]!.tabs).toEqual([]);
expect(r.state.activePane).toBe(r.paneId);
expect(paths(r.state, pane)).toEqual(["/a"]);
});
});
+14 -6
View File
@@ -425,21 +425,29 @@ export function moveTabToNewSplit(
const tab = src?.tabs.find((t) => t.id === tabId);
if (!src || !tab || !s.panes[targetPane]) return null;
if (fromPane === targetPane && src.tabs.length === 1) return null;
let next = { ...removeTabs(s, fromPane, new Set([tabId])), closed: s.closed };
const next = { ...removeTabs(s, fromPane, new Set([tabId])), closed: s.closed };
// The source pane may have collapsed away; split the target if it survived, else the active pane.
const anchor = next.panes[targetPane] ? targetPane : next.activePane;
const id = nextId("pane");
const moved: Tab = { ...tab, preview: false };
const pane: Pane = { id, tabs: [moved], active: moved.id, mru: [moved.id] };
return splitAtEdge(next, anchor, edge, { tabs: [moved], active: moved.id, mru: [moved.id] });
}
/** A new pane (empty unless `content` is given) at `edge` of `anchor`, made active. */
export function splitAtEdge(
s: TabsState,
anchor: string,
edge: SplitEdge,
content: Omit<Pane, "id"> = { tabs: [], active: null, mru: [] },
): { state: TabsState; paneId: string } {
const id = nextId("pane");
const dir = edge === "left" || edge === "right" ? "row" : "column";
const before = edge === "left" || edge === "top";
const layout = mapLayout(next.layout, (n) => {
const layout = mapLayout(s.layout, (n) => {
if (n.kind === "pane" && n.paneId === anchor) {
const added: LayoutNode = { kind: "pane", paneId: id };
return { kind: "split", id: nextId("split"), dir, children: before ? [added, n] : [n, added] };
}
return n;
})!;
next = { ...next, layout, panes: { ...next.panes, [id]: pane }, activePane: id };
return { state: next, paneId: id };
return { state: { ...s, layout, panes: { ...s.panes, [id]: { id, ...content } }, activePane: id }, paneId: id };
}
+31 -29
View File
@@ -4,7 +4,7 @@
* decorations, middle-click close, Alt+click close others, double-click to
* keep a preview / maximize the editor, context menu) and the active editor.
*/
import { Fragment, useMemo, useRef, useState, type DragEvent, type FocusEvent, type ReactNode } from "react";
import { Fragment, useEffect, useMemo, useRef, useState, type FocusEvent, type ReactNode } from "react";
import clsx from "clsx";
import { useShallow } from "zustand/react/shallow";
import { executeAction } from "@/platform/actions";
@@ -32,6 +32,8 @@ import {
SplitPanel,
Tabs,
showContextMenu,
registerDropTarget,
FILES_DRAG,
type TabItem,
type TabsDnd,
} from "@/ui";
@@ -197,6 +199,33 @@ function PaneView({ paneId }: { paneId: string }) {
return { all: strip(0), rest: strip(pinnedCount) };
}, [ctrl, paneId, pinnedCount]);
// Dropping a tab or project files on the editor: an edge splits, the middle opens / moves here.
const contentRef = useRef<HTMLDivElement>(null);
const hasPane = !!pane;
useEffect(() => {
const el = contentRef.current;
if (!el) return;
const zoneAt = (x: number, y: number) => dropZoneAt(x, y, el.getBoundingClientRect());
return registerDropTarget(el, {
accepts: (type) => type === EDITOR_TAB_MIME || type === FILES_DRAG,
over: (_, x, y) => setDropZone(zoneAt(x, y)),
leave: () => setDropZone(null),
drop: (data, x, y) => {
const zone = zoneAt(x, y);
setDropZone(null);
if (data.type === FILES_DRAG) {
void ctrl.openFilesIn(data.payload.split("\n").filter(Boolean), paneId, zone);
return;
}
const p = parseTabPayload(data.payload);
if (!p) return;
const target = useTabs.getState().panes[paneId];
if (zone === "center") ctrl.moveTab(p.paneId, p.tabId, paneId, target?.tabs.length ?? 0);
else ctrl.moveTabToSplit(p.paneId, p.tabId, paneId, zone);
},
});
}, [ctrl, paneId, hasPane]);
if (!pane) return null;
const active = pane.tabs.find((t) => t.id === pane.active) ?? null;
@@ -208,33 +237,6 @@ function PaneView({ paneId }: { paneId: string }) {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setContext("editorFocus", false);
};
// Dropping a tab on the editor: an edge splits, the middle moves the tab here.
const isTabDrag = (e: DragEvent) => [...e.dataTransfer.types].includes(EDITOR_TAB_MIME);
const contentDnd = {
onDragOverCapture: (e: DragEvent<HTMLDivElement>) => {
if (!isTabDrag(e)) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "move";
const zone = dropZoneAt(e.clientX, e.clientY, e.currentTarget.getBoundingClientRect());
if (zone !== dropZone) setDropZone(zone);
},
onDragLeave: (e: DragEvent<HTMLDivElement>) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropZone(null);
},
onDropCapture: (e: DragEvent<HTMLDivElement>) => {
if (!isTabDrag(e)) return;
e.preventDefault();
e.stopPropagation();
const zone = dropZoneAt(e.clientX, e.clientY, e.currentTarget.getBoundingClientRect());
setDropZone(null);
const p = parseTabPayload(e.dataTransfer.getData(EDITOR_TAB_MIME));
if (!p) return;
if (zone === "center") ctrl.moveTab(p.paneId, p.tabId, paneId, pane.tabs.length);
else ctrl.moveTabToSplit(p.paneId, p.tabId, paneId, zone);
},
};
const hidden = tabPrefs.placement === "none" || distractionFree;
const strip = (stripItems: TabItem[], stripDnd: TabsDnd, trailing: ReactNode) => (
<Tabs
@@ -299,7 +301,7 @@ function PaneView({ paneId }: { paneId: string }) {
role="tabpanel"
id={active ? `editor-${paneId}-panel-${active.id}` : undefined}
aria-labelledby={active ? `editor-${paneId}-tab-${active.id}` : undefined}
{...contentDnd}
ref={contentRef}
>
{active ? <PaneContent paneId={paneId} tab={active} /> : <EmptyEditor />}
{dropZone && <div className={clsx(styles.dropZone, styles[`drop-${dropZone}`])} aria-hidden="true" />}
+13
View File
@@ -28,14 +28,17 @@ import { useEffectiveRoots } from "@/platform/projectRoots";
import { macStroke, strokeOf } from "@/platform/keymap";
import { prepareMenuGroups, useFileDecoration } from "@/workbench/contributions";
import {
FILES_DRAG,
HighlightedText,
Icon,
Popup,
dragSession,
Tree,
acquirePopup,
fileIconName,
folderIconName,
showContextMenu,
type DragSession,
type TreeHandle,
type TreeRowState,
} from "@/ui";
@@ -443,6 +446,8 @@ export function ProjectView() {
startY = e.clientY;
let active = false;
let paths: string[] = [];
// Files (not folders) can also be dropped on an editor to open them there.
let files: DragSession | null = null;
const move = (ev: PointerEvent) => {
if (!active) {
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < DRAG_THRESHOLD) return;
@@ -454,10 +459,14 @@ export function ProjectView() {
const nodes = keys.map((k) => m.byKey.get(k)).filter((n): n is ProjectNode => !!n && !n.root && !n.placeholder);
paths = nodes.map((n) => n.path);
if (!paths.length) return cleanup();
const filePaths = nodes.filter((n) => !n.dir).map((n) => n.path);
if (filePaths.length) files = dragSession({ type: FILES_DRAG, payload: filePaths.join("\n") });
}
const target = folderAt(containerRef.current, ev.clientX, ev.clientY);
const valid = target && !paths.some((p) => target.path === p || isInside(target.path, p));
setDropTarget(valid ? target.key : null);
if (target) files?.cancel();
else files?.move(ev.clientX, ev.clientY);
setDrag({
paths,
label: paths.length === 1 ? nameOf(paths[0]!) : tx`${paths.length} items`,
@@ -469,8 +478,11 @@ export function ProjectView() {
const up = (ev: PointerEvent) => {
const target = active ? folderAt(containerRef.current, ev.clientX, ev.clientY) : null;
const valid = target && !paths.some((p) => target.path === p || isInside(target.path, p));
const session = files;
files = null;
cleanup();
if (active && valid) void dropPaths(paths, target.path, ev.altKey);
else if (active && !target) session?.drop(ev.clientX, ev.clientY);
};
const key = (ev: KeyboardEvent) => {
if (ev.key === "Escape" && active) {
@@ -483,6 +495,7 @@ export function ProjectView() {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("keydown", key, true);
files?.cancel();
setDropTarget(null);
setDrag(null);
};
+3 -9
View File
@@ -8,6 +8,7 @@
import { executeAction, type Action } from "@/platform/actions";
import { call, commands, isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { setDragRequiresAlt } from "@/ui";
import { ZOOM_LIMITS } from "./model/behaviorSchema";
import { DEFAULT_SETTINGS, validateSettings, type ValidSettings } from "./model/schema";
@@ -126,16 +127,9 @@ export function zoomActions(): Action[] {
// ---------------------------------------------------------------------------
// Drag-and-drop with Alt pressed only (tool window buttons and editor tabs)
const DRAG_SOURCES = "[data-stripe-button], [role='tab']";
export function installAltDragGuard(enabled: () => boolean): () => void {
const onDragStart = (e: DragEvent) => {
if (!enabled() || e.altKey) return;
const el = e.target instanceof Element ? e.target : null;
if (el?.closest(DRAG_SOURCES)) e.preventDefault();
};
document.addEventListener("dragstart", onDragStart, true);
return () => document.removeEventListener("dragstart", onDragStart, true);
setDragRequiresAlt(enabled);
return () => setDragRequiresAlt(() => false);
}
// ---------------------------------------------------------------------------
+35 -29
View File
@@ -1,6 +1,6 @@
/** Commit tool window: change list with inclusion checkboxes, message, stashes. */
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type PointerEvent } from "react";
import { tx } from "@/platform/i18n";
import clsx from "clsx";
import { executeAction } from "@/platform/actions";
@@ -18,7 +18,9 @@ import {
Tabs,
Tree,
fileIconName,
registerDropTarget,
showContextMenu,
startPointerDrag,
type MenuItem,
type TreeHandle,
T,
@@ -424,35 +426,39 @@ function ChangesPanel() {
{n.label}
</span>
);
// Pointer-driven (HTML5 drag and drop never reaches the Tauri webview):
// files and folders drag, changelist rows take them.
const list = n.changelist;
const dnd =
n.changelist !== undefined
? {
draggable: n.type !== "group",
onDragStart: (e: DragEvent<HTMLSpanElement>) => {
const paths =
selected.has(n.key) && selected.size > 1
? filesOf(selectedNodes()).map((f) => f.path)
: n.files.map((f) => f.path);
e.dataTransfer.setData(CHANGES_MIME, paths.map(stripSlash).join("\n"));
e.dataTransfer.effectAllowed = "move";
},
onDragOver: (e: DragEvent<HTMLSpanElement>) => {
if (![...e.dataTransfer.types].includes(CHANGES_MIME)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (dropTarget !== n.changelist) setDropTarget(n.changelist ?? null);
},
onDragLeave: () => setDropTarget(null),
onDrop: (e: DragEvent<HTMLSpanElement>) => {
const data = e.dataTransfer.getData(CHANGES_MIME);
setDropTarget(null);
if (!data || !n.changelist) return;
e.preventDefault();
moveToChangelist(data.split("\n"), n.changelist);
},
onDragEnd: () => setDropTarget(null),
}
: {};
list === undefined
? {}
: n.type === "group"
? {
ref: (el: HTMLSpanElement | null) =>
el
? registerDropTarget(el, {
accepts: (type) => type === CHANGES_MIME,
over: () => setDropTarget(list),
leave: () => setDropTarget(null),
drop: (data) => {
setDropTarget(null);
moveToChangelist(data.payload.split("\n"), list);
},
})
: undefined,
}
: {
onPointerDown: (e: PointerEvent<HTMLSpanElement>) => {
const paths =
selected.has(n.key) && selected.size > 1
? filesOf(selectedNodes()).map((f) => f.path)
: n.files.map((f) => f.path);
startPointerDrag(e, e.currentTarget, {
type: CHANGES_MIME,
payload: paths.map(stripSlash).join("\n"),
});
},
};
return (
<span
className={clsx(
+39 -40
View File
@@ -11,7 +11,6 @@ import {
useRef,
useState,
type CSSProperties,
type DragEvent,
type KeyboardEvent,
type MouseEvent,
type ReactNode,
@@ -21,6 +20,7 @@ import { Icon, type IconName } from "./icons";
import { IconButton } from "./Button";
import { Menu, type MenuItem } from "./Menu";
import { tooltipAttrs } from "./Tooltip";
import { registerDropTarget, startPointerDrag } from "./pointerDrag";
import { T } from "./T";
import styles from "./Tabs.module.css";
import { tx } from "@/platform/i18n";
@@ -148,37 +148,36 @@ export function Tabs({
[register],
);
const accepts = (e: DragEvent) => !!dnd && [...e.dataTransfer.types].includes(dnd.type);
const indexAt = (e: DragEvent) =>
dropIndex(
vertical ? e.clientY : e.clientX,
items.map((it) => {
const r = tabRefs.current.get(it.id)?.parentElement?.getBoundingClientRect();
return vertical ? { left: r?.top ?? 0, width: r?.height ?? 0 } : { left: r?.left ?? 0, width: r?.width ?? 0 };
}),
);
const dropHandlers = dnd
? {
onDragOver: (e: DragEvent<HTMLDivElement>) => {
if (!accepts(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const i = indexAt(e);
if (i !== drop) setDrop(i);
},
onDragLeave: (e: DragEvent<HTMLDivElement>) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDrop(null);
},
onDrop: (e: DragEvent<HTMLDivElement>) => {
if (!accepts(e)) return;
e.preventDefault();
const payload = e.dataTransfer.getData(dnd.type);
const i = indexAt(e);
setDrop(null);
if (payload) dnd.onDrop(payload, i);
},
}
: {};
const stripRef = useRef<HTMLDivElement>(null);
// Re-read on every drag event: items and handlers change while the strip stays registered.
const dropState = useRef({ items, dnd, vertical });
useLayoutEffect(() => {
dropState.current = { items, dnd, vertical };
});
const dndType = dnd?.type;
useEffect(() => {
const el = stripRef.current;
if (!el || !dndType) return;
const indexAt = (x: number, y: number) => {
const { items, vertical } = dropState.current;
return dropIndex(
vertical ? y : x,
items.map((it) => {
const r = tabRefs.current.get(it.id)?.parentElement?.getBoundingClientRect();
return vertical ? { left: r?.top ?? 0, width: r?.height ?? 0 } : { left: r?.left ?? 0, width: r?.width ?? 0 };
}),
);
};
return registerDropTarget(el, {
accepts: (type) => type === dropState.current.dnd?.type,
over: (_, x, y) => setDrop(indexAt(x, y)),
leave: () => setDrop(null),
drop: (data, x, y) => {
setDrop(null);
dropState.current.dnd?.onDrop(data.payload, indexAt(x, y));
},
});
}, [dndType]);
useLayoutEffect(() => {
const el = scrollRef.current;
@@ -249,7 +248,7 @@ export function Tabs({
closePosition === "left" && styles.closeLeft,
className,
)}
{...dropHandlers}
ref={stripRef}
>
{vertical && trailing && <div className={styles.trailing}>{trailing}</div>}
<div
@@ -279,17 +278,17 @@ export function Tabs({
item.tint && styles.tinted,
)}
style={item.tint ? ({ "--tab-tint": item.tint } as CSSProperties) : undefined}
draggable={!!dnd}
onDragStart={
onPointerDown={
dnd
? (e) => {
e.dataTransfer.setData(dnd.type, dnd.payload(item.id));
e.dataTransfer.effectAllowed = "move";
setDragging(item.id);
if ((e.target as Element).closest("[data-tab-close]")) return;
startPointerDrag(e, e.currentTarget, { type: dnd.type, payload: dnd.payload(item.id) }, {
onStart: () => setDragging(item.id),
onEnd: () => setDragging(null),
});
}
: undefined
}
onDragEnd={dnd ? () => (setDragging(null), setDrop(null)) : undefined}
onMouseDown={(e) => {
if (e.button === 1 && closable) {
e.preventDefault();
@@ -324,7 +323,7 @@ export function Tabs({
{item.modified && !closable && <span className={styles.modified} aria-label="Modified" />}
</button>
{closable && (
<span className={styles.closeSlot}>
<span className={styles.closeSlot} data-tab-close>
{item.modified && <span className={styles.modified} aria-label="Modified" />}
<IconButton
icon="close"
+1
View File
@@ -6,6 +6,7 @@ export * from "./Input";
export * from "./Checkbox";
export * from "./Select";
export * from "./Tabs";
export { FILES_DRAG, dragRequiresAlt, dragSession, registerDropTarget, setDragRequiresAlt, startPointerDrag, type DragData, type DragSession, type DropTarget } from "./pointerDrag";
export * from "./Tree";
export * from "./List";
export * from "./Popup";
+19
View File
@@ -0,0 +1,19 @@
.ghost {
position: fixed;
z-index: var(--z-tooltip);
margin: 0;
opacity: 0.85;
pointer-events: none;
box-shadow: var(--shadow-tooltip);
background: var(--bg-popup);
}
.noTarget {
opacity: 0.5;
}
.dragging,
.dragging * {
cursor: grabbing !important;
user-select: none !important;
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Pointer-driven drag & drop between components. HTML5 drag and drop can't be
* used: with the window's native file drop enabled, the webview never sees
* dragover / drop for drags that start inside it. Sources start a drag with
* `startPointerDrag` (or drive a `DragSession` themselves); targets register
* an element with `registerDropTarget` and are told about drags over it.
*/
import styles from "./pointerDrag.module.css";
/** What is dragged: `type` is matched by targets (e.g. `application/x-openidea-editor-tab`). */
export interface DragData {
type: string;
payload: string;
}
/** Project files dragged from the Project view: `payload` is the paths joined by `\n`. */
export const FILES_DRAG = "application/x-openidea-files";
export interface DropTarget {
accepts(type: string): boolean;
/** The pointer moved over the target. */
over(data: DragData, x: number, y: number): void;
/** The pointer left the target, or the drag ended elsewhere / was cancelled. */
leave(): void;
drop(data: DragData, x: number, y: number): void;
}
const targets = new Map<Element, DropTarget>();
export function registerDropTarget(el: Element, target: DropTarget): () => void {
targets.set(el, target);
return () => {
if (targets.get(el) === target) targets.delete(el);
};
}
/** The innermost registered target under (x, y) accepting `type`. */
function targetAt(type: string, x: number, y: number): DropTarget | null {
for (let el = document.elementFromPoint(x, y); el; el = el.parentElement) {
const t = targets.get(el);
if (t?.accepts(type)) return t;
}
return null;
}
export interface DragSession {
/** Updates hover feedback; true when a target under (x, y) accepts the drag. */
move(x: number, y: number): boolean;
/** Drops on the target under (x, y); false when there is none. */
drop(x: number, y: number): boolean;
cancel(): void;
}
export function dragSession(data: DragData): DragSession {
let current: DropTarget | null = null;
const enter = (t: DropTarget | null) => {
if (t === current) return;
current?.leave();
current = t;
};
return {
move(x, y) {
enter(targetAt(data.type, x, y));
current?.over(data, x, y);
return !!current;
},
drop(x, y) {
const t = targetAt(data.type, x, y);
enter(null);
t?.drop(data, x, y);
return !!t;
},
cancel: () => enter(null),
};
}
/** Movement before a press becomes a drag, px. */
const DRAG_THRESHOLD = 4;
/** Settings › Appearance › "Drag-and-drop with Alt pressed only" for tabs and stripe buttons. */
let requiresAlt = () => false;
export function setDragRequiresAlt(fn: () => boolean) {
requiresAlt = fn;
}
export const dragRequiresAlt = () => requiresAlt();
/**
* Starts a drag from a primary-button press on `source` once the pointer moved
* past the threshold; a ghost copy of `source` follows the pointer. Escape
* cancels. `onEnd` gets whether the drag happened (to swallow the click).
*/
export function startPointerDrag(
e: { button: number; clientX: number; clientY: number; altKey: boolean },
source: HTMLElement,
data: DragData,
opts: { onStart?: () => void; onEnd?: (dragged: boolean) => void } = {},
) {
if (e.button !== 0 || (requiresAlt() && !e.altKey)) return;
const start = { x: e.clientX, y: e.clientY };
let ghost: HTMLElement | null = null;
const session = dragSession(data);
const onMove = (ev: PointerEvent) => {
if (!ghost) {
if (Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < DRAG_THRESHOLD) return;
const r = source.getBoundingClientRect();
ghost = source.cloneNode(true) as HTMLElement;
ghost.removeAttribute("id");
ghost.querySelectorAll("[id]").forEach((n) => n.removeAttribute("id"));
ghost.classList.add(styles.ghost!);
ghost.setAttribute("aria-hidden", "true");
Object.assign(ghost.style, {
left: `${r.left}px`,
top: `${r.top}px`,
width: `${r.width}px`,
height: `${r.height}px`,
});
document.body.appendChild(ghost);
document.documentElement.classList.add(styles.dragging!);
opts.onStart?.();
}
ghost.style.transform = `translate(${ev.clientX - start.x}px, ${ev.clientY - start.y}px)`;
ghost.classList.toggle(styles.noTarget!, !session.move(ev.clientX, ev.clientY));
};
const onUp = (ev: PointerEvent) => {
const dragged = !!ghost;
cleanup();
if (dragged) session.drop(ev.clientX, ev.clientY);
opts.onEnd?.(dragged);
};
const onKey = (ev: KeyboardEvent) => {
if (ev.key !== "Escape" || !ghost) return;
ev.preventDefault();
ev.stopPropagation();
session.cancel();
cleanup();
opts.onEnd?.(true);
};
const cleanup = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onCancel);
window.removeEventListener("keydown", onKey, true);
ghost?.remove();
document.documentElement.classList.remove(styles.dragging!);
};
const onCancel = () => {
session.cancel();
const dragged = !!ghost;
cleanup();
opts.onEnd?.(dragged);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onCancel);
window.addEventListener("keydown", onKey, true);
}
+2
View File
@@ -379,6 +379,8 @@ export interface OpenOptions {
preview?: boolean;
/** Open to the side (split right). */
side?: boolean;
/** Open in this editor pane, in a tab of its own even when another pane shows the file. */
pane?: string;
focus?: boolean;
/** Open in the text editor even when a file editor provider claims the file (SVG as text). */
asText?: boolean;
+2 -2
View File
@@ -15,7 +15,7 @@ import { create } from "zustand";
import { call, commands } from "@/platform/ipc";
import { shortcutLabel } from "@/platform/keymap";
import { useProject } from "@/platform/project";
import { Icon, IconButton, Menu, showContextMenu, tooltipAttrs, type MenuItem, T } from "@/ui";
import { dragRequiresAlt, Icon, IconButton, Menu, showContextMenu, tooltipAttrs, type MenuItem, T } from "@/ui";
import { FOCUSABLE } from "@/ui/popupState";
import { TabStripRegistry } from "@/ui/Tabs";
import { useContributions, type ToolWindowAnchor, type ToolWindowContribution } from "../contributions";
@@ -303,7 +303,7 @@ function stripeDropAt(x: number, y: number): StripeDrop | null {
}
function startStripeDrag(e: PointerEvent<HTMLButtonElement>, id: string) {
if (e.button !== 0) return;
if (e.button !== 0 || (dragRequiresAlt() && !e.altKey)) return;
const button = e.currentTarget;
const start = { x: e.clientX, y: e.clientY };
let ghost: HTMLElement | null = null;