mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
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:
1 parent
48ac2094fc
commit
60722e0cc5
13 files changed
+345
-118
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user