дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
feat(editor): drag an editor tab out to a standalone window (#470)
Dragging an editor tab past the window edge, or choosing Open in New Window from its menu, now opens the file in its own window and closes the tab here. A text file is saved first and a custom document runs its Save / Do not Save / Cancel guard once; if saving or opening fails, the tab stays. The new window is forced even when the project is already open elsewhere, and a file outside the project opens as LightEdit. Dragging out tool windows is not covered. Refs #454 Co-authored-by: a-rookie-of-C-language <[email protected]>
Цей коміт міститься в:
1 джерело
986b0ade58
коміт
2754cf327f
12 files changed
+345
-12
No files matched your search
@@ -340,6 +340,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
sdk::sdk_python_pip,
|
||||
window::window_open_project,
|
||||
window::window_open_files,
|
||||
window::window_open_file_in_new_window,
|
||||
window::window_open_remote,
|
||||
window::window_new,
|
||||
window::window_open_settings,
|
||||
|
||||
@@ -39,6 +39,19 @@ pub async fn window_open_files(
|
||||
Ok(windows::open_files_window(&app, window.label(), files))
|
||||
}
|
||||
|
||||
/// Detaches an editor tab: opens `file` in a forced new window (`project`, None
|
||||
/// for LightEdit, decided by the caller), bypassing the focus-an-existing-window
|
||||
/// routing of [`window_open_project`] / [`window_open_files`].
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn window_open_file_in_new_window(
|
||||
app: AppHandle,
|
||||
project: Option<PathBuf>,
|
||||
file: PathBuf,
|
||||
) -> IdeResult<OpenOutcome> {
|
||||
windows::open_file_in_new_window(&app, project, file)
|
||||
}
|
||||
|
||||
/// [`window_open_project`] for folder `path` on the host of SSH configuration
|
||||
/// `ssh_config` (Remote Development); a new window connects itself.
|
||||
#[tauri::command]
|
||||
|
||||
@@ -411,6 +411,16 @@ pub fn open_project_window(
|
||||
Ok(OpenOutcome::Opened { label: window.label().to_owned() })
|
||||
}
|
||||
|
||||
/// Opens `file` in a forced new window: `project` (None: LightEdit) plus the
|
||||
/// single file, always creating a window even when the project is already open
|
||||
/// in another one (unlike [`open_project_window`], which focuses it). Used to
|
||||
/// detach an editor tab out of its window.
|
||||
pub fn open_file_in_new_window(app: &AppHandle, project: Option<PathBuf>, file: PathBuf) -> IdeResult<OpenOutcome> {
|
||||
tracing::info!("open_file_in_new_window {} project={:?}", file.display(), project);
|
||||
let window = create_window_with(app, project, vec![file])?;
|
||||
Ok(OpenOutcome::Opened { label: window.label().to_owned() })
|
||||
}
|
||||
|
||||
/// Loose local `files` asked for by window `caller` (File | Open File, a drop on the Welcome
|
||||
/// screen), routed like [`open_paths`]; returns the files `caller` opens itself.
|
||||
pub fn open_files_window(app: &AppHandle, caller: &str, files: Vec<PathBuf>) -> Vec<ide_core::IpcPath> {
|
||||
|
||||
@@ -686,6 +686,12 @@ export const commands = {
|
||||
* itself, in LightEdit.
|
||||
*/
|
||||
windowOpenFiles: (files: string[]) => typedError<IpcPath[], IdeError>(__TAURI_INVOKE("window_open_files", { files })),
|
||||
/**
|
||||
* Detaches an editor tab: opens `file` in a forced new window (`project`, None
|
||||
* for LightEdit, decided by the caller), bypassing the focus-an-existing-window
|
||||
* routing of [`window_open_project`] / [`window_open_files`].
|
||||
*/
|
||||
windowOpenFileInNewWindow: (project: string | null, file: string) => typedError<OpenOutcome, IdeError>(__TAURI_INVOKE("window_open_file_in_new_window", { project, file })),
|
||||
/**
|
||||
* [`window_open_project`] for folder `path` on the host of SSH configuration
|
||||
* `ssh_config` (Remote Development); a new window connects itself.
|
||||
|
||||
@@ -7,6 +7,7 @@ import { EditorSelection } from "@codemirror/state";
|
||||
import { create } from "zustand";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { isSameOrInside } from "@/platform/paths";
|
||||
import { basename, useProject } from "@/platform/project";
|
||||
import { editorPrefs } from "@/platform/editorSettings";
|
||||
import {
|
||||
@@ -31,6 +32,8 @@ import {
|
||||
import { useDistractionFree } from "@/workbench/distractionFree";
|
||||
import { ask } from "@/workbench/messages";
|
||||
import { t } from "@/platform/i18n";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { isRemoteWindow } from "@/platform/remote";
|
||||
import { fileIconName, showContextMenu, type MenuItem } from "@/ui";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import {
|
||||
@@ -1059,7 +1062,7 @@ export class EditorController implements SlotHost {
|
||||
/** Close exactly the originally selected tabs, evaluating references after every await. */
|
||||
private removeGuarded(
|
||||
select: (s: TabsState) => TabsState,
|
||||
options: { protect?: (tab: Tab) => boolean; clearHistory?: boolean } = {},
|
||||
options: { protect?: (tab: Tab) => boolean; clearHistory?: boolean; beforeRemove?: () => Promise<boolean> } = {},
|
||||
): Promise<boolean> {
|
||||
const generation = this.projectGeneration;
|
||||
const initial = useTabs.getState();
|
||||
@@ -1129,6 +1132,7 @@ export class EditorController implements SlotHost {
|
||||
}
|
||||
authorized.add(d.input.key);
|
||||
}
|
||||
if (options.beforeRemove && !(await options.beforeRemove())) return false;
|
||||
// Commit only the original target ids against the latest tab state.
|
||||
if (generation !== this.projectGeneration) return false;
|
||||
const before = useTabs.getState();
|
||||
@@ -1303,6 +1307,46 @@ export class EditorController implements SlotHost {
|
||||
changeSplitOrientation(paneId: string) {
|
||||
this.apply((s) => changeSplitOrientation(s, paneId));
|
||||
}
|
||||
/** Save or resolve the dirty guard, open the window, then remove the original tab. */
|
||||
async detachTab(paneId: string, tabId: string): Promise<boolean> {
|
||||
const tab = useTabs.getState().panes[paneId]?.tabs.find((t) => t.id === tabId);
|
||||
const path = tab?.input.path;
|
||||
if (
|
||||
!tab ||
|
||||
!path ||
|
||||
(tab.input.kind !== "file" && !documentLifecycle(tab.input, useContributions.getState().editorKinds))
|
||||
)
|
||||
return false;
|
||||
if (isRemoteWindow()) {
|
||||
notifications().notify({ level: "warning", title: t("app.window.desktopOnly") });
|
||||
return false;
|
||||
}
|
||||
const project = useProject.getState().project;
|
||||
const inProject =
|
||||
!!project && (project.workspace?.projects ?? [project.root]).some((root) => isSameOrInside(path, root));
|
||||
return this.removeGuarded((s) => (s.panes[paneId] ? closeTab(s, paneId, tabId) : s), {
|
||||
beforeRemove: async () => {
|
||||
if (!useTabs.getState().panes[paneId]?.tabs.some((t) => t.id === tabId)) return false;
|
||||
if (isTextTab(tab)) {
|
||||
const slot = await this.ensureSlot(tabId);
|
||||
if (!slot) return false;
|
||||
await slot.model.save("explicit");
|
||||
}
|
||||
try {
|
||||
await call(commands.windowOpenFileInNewWindow(inProject ? project.root : null, path));
|
||||
return true;
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: t("app.window.openFailed", { name: basename(path) }),
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
key: `app.open:${path}`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
/** IDEA "Open in Opposite Group": the tab stays and a copy opens next door (or in a new split). */
|
||||
openInOpposite(paneId: string, tabId: string) {
|
||||
const s = useTabs.getState();
|
||||
@@ -1603,6 +1647,10 @@ export class EditorController implements SlotHost {
|
||||
{ label: "Unsplit All", run: () => void this.unsplitAll(paneId) },
|
||||
] as MenuItem[])
|
||||
: []),
|
||||
...(tab.input.path &&
|
||||
(tab.input.kind === "file" || documentLifecycle(tab.input, useContributions.getState().editorKinds))
|
||||
? ([{ label: "Open in New Window", run: () => void this.detachTab(paneId, tab.id) }] as MenuItem[])
|
||||
: []),
|
||||
{ kind: "separator" },
|
||||
...(path
|
||||
? ([
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorController, useTabs } from "./controller";
|
||||
import { createMemoryBackend } from "./backend";
|
||||
import { initialTabsState } from "./model/tabs";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { commands } from "@/platform/ipc";
|
||||
import * as contributions from "@/workbench/contributions";
|
||||
import { ask } from "@/workbench/messages";
|
||||
import type * as Messages from "@/workbench/messages";
|
||||
import { registerEditorKind, type EditorDocumentLifecycle } from "@/workbench/contributions";
|
||||
|
||||
vi.mock("@/workbench/messages", async (original) => ({
|
||||
...(await original<typeof Messages>()),
|
||||
ask: vi.fn(),
|
||||
}));
|
||||
|
||||
const notify = vi.fn();
|
||||
let ctrl: EditorController;
|
||||
let unregister: (() => void) | undefined;
|
||||
const path = "/p/a.ts";
|
||||
const current = () => {
|
||||
const s = useTabs.getState();
|
||||
return [s.activePane, s.panes[s.activePane]!.active!] as const;
|
||||
};
|
||||
const hasTab = (id: string) => Object.values(useTabs.getState().panes).some((p) => p.tabs.some((t) => t.id === id));
|
||||
|
||||
beforeEach(() => {
|
||||
notify.mockReset();
|
||||
vi.spyOn(contributions, "notifications").mockReturnValue({ notify });
|
||||
useTabs.setState(initialTabsState(), true);
|
||||
useProject.setState({ project: null, settings: { files: { autoSave: false } } as never });
|
||||
vi.spyOn(commands, "windowOpenFileInNewWindow").mockResolvedValue({
|
||||
status: "ok",
|
||||
data: { kind: "opened", label: "second" },
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
ctrl?.stop();
|
||||
unregister?.();
|
||||
unregister = undefined;
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(ask).mockReset();
|
||||
useProject.setState({ project: null, settings: null });
|
||||
});
|
||||
|
||||
describe("detachTab", () => {
|
||||
it.each([false, true])("waits for text save before opening and closing, autosave=%s", async (autoSave) => {
|
||||
useProject.setState({ settings: { files: { autoSave } } as never });
|
||||
const backend = createMemoryBackend({ [path]: "original\n" });
|
||||
ctrl = new EditorController(backend);
|
||||
ctrl.start();
|
||||
await ctrl.openFile(path, { focus: false });
|
||||
const [pane, id] = current();
|
||||
ctrl.slotFor(id)!.dispatchLocal({ changes: { from: 0, to: 8, insert: "edited" } });
|
||||
let finish!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const originalSave = backend.save;
|
||||
const save = vi.spyOn(backend, "save").mockImplementation(async (doc) => {
|
||||
await gate;
|
||||
await originalSave(doc);
|
||||
});
|
||||
vi.mocked(commands.windowOpenFileInNewWindow).mockImplementation(async () => {
|
||||
expect(hasTab(id)).toBe(true);
|
||||
expect((await backend.snapshot(ctrl.slotFor(id)!.model.doc)).text).toBe("edited\n");
|
||||
return { status: "ok", data: { kind: "opened", label: "second" } };
|
||||
});
|
||||
const result = ctrl.detachTab(pane, id);
|
||||
await vi.waitFor(() => expect(save).toHaveBeenCalled());
|
||||
expect(commands.windowOpenFileInNewWindow).not.toHaveBeenCalled();
|
||||
expect(hasTab(id)).toBe(true);
|
||||
finish();
|
||||
expect(await result).toBe(true);
|
||||
expect(hasTab(id)).toBe(false);
|
||||
expect(ask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the tab and opens no window when text saving fails", async () => {
|
||||
const backend = createMemoryBackend({ [path]: "original\n" });
|
||||
ctrl = new EditorController(backend);
|
||||
ctrl.start();
|
||||
await ctrl.openFile(path, { focus: false });
|
||||
const [pane, id] = current();
|
||||
vi.spyOn(backend, "save").mockRejectedValue(new Error("save failed"));
|
||||
expect(await ctrl.detachTab(pane, id)).toBe(false);
|
||||
expect(hasTab(id)).toBe(true);
|
||||
expect(commands.windowOpenFileInNewWindow).not.toHaveBeenCalled();
|
||||
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error", body: "save failed" }));
|
||||
});
|
||||
|
||||
it("keeps a pinned text tab if window creation fails", async () => {
|
||||
ctrl = new EditorController(createMemoryBackend({ [path]: "original\n" }));
|
||||
ctrl.start();
|
||||
await ctrl.openFile(path, { focus: false });
|
||||
const [pane, id] = current();
|
||||
ctrl.togglePin(pane, id);
|
||||
vi.mocked(commands.windowOpenFileInNewWindow).mockRejectedValue(new Error("window failed"));
|
||||
expect(await ctrl.detachTab(pane, id)).toBe(false);
|
||||
expect(useTabs.getState().panes[pane]!.tabs.find((t) => t.id === id)?.pinned).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["save", "discard", "cancel", "save failure", "window failure"])(
|
||||
"runs the custom document guard once: %s",
|
||||
async (decision) => {
|
||||
const events: string[] = [];
|
||||
const lifecycle: EditorDocumentLifecycle = {
|
||||
isDirty: () => true,
|
||||
flush: async () => {},
|
||||
prepareClose: async () => {
|
||||
events.push("prepare");
|
||||
},
|
||||
cancelClose: () => {
|
||||
events.push("cancelClose");
|
||||
},
|
||||
save: async () => {
|
||||
events.push("save");
|
||||
if (decision === "save failure") throw new Error("save failed");
|
||||
},
|
||||
discard: async () => {
|
||||
events.push("discard");
|
||||
},
|
||||
close: async () => {
|
||||
events.push("close");
|
||||
},
|
||||
};
|
||||
unregister = registerEditorKind({
|
||||
kind: "detach-test",
|
||||
title: () => "test",
|
||||
component: () => null,
|
||||
document: lifecycle,
|
||||
});
|
||||
ctrl = new EditorController(createMemoryBackend());
|
||||
ctrl.start();
|
||||
await ctrl.openInput({ kind: "detach-test", key: "custom", path: "/p/a.ipynb" }, { focus: false });
|
||||
const [pane, id] = current();
|
||||
vi.mocked(ask).mockImplementation(async () => {
|
||||
events.push("ask");
|
||||
expect(commands.windowOpenFileInNewWindow).not.toHaveBeenCalled();
|
||||
return {
|
||||
button: decision === "save failure" || decision === "window failure" ? "save" : decision,
|
||||
checked: false,
|
||||
};
|
||||
});
|
||||
vi.mocked(commands.windowOpenFileInNewWindow).mockImplementation(async () => {
|
||||
events.push("open");
|
||||
expect(hasTab(id)).toBe(true);
|
||||
if (decision === "window failure") throw new Error("window failed");
|
||||
return { status: "ok", data: { kind: "opened", label: "second" } };
|
||||
});
|
||||
const success = decision === "save" || decision === "discard";
|
||||
expect(await ctrl.detachTab(pane, id)).toBe(success);
|
||||
expect(hasTab(id)).toBe(!success);
|
||||
expect(ask).toHaveBeenCalledOnce();
|
||||
expect(events).toEqual(
|
||||
success
|
||||
? ["prepare", "ask", decision, "open", "close"]
|
||||
: decision === "cancel"
|
||||
? ["prepare", "ask", "cancelClose"]
|
||||
: decision === "save failure"
|
||||
? ["prepare", "ask", "save", "cancelClose"]
|
||||
: ["prepare", "ask", "save", "open", "cancelClose"],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["C:\\project\\a.ts", ["C:/project"], "C:/project"],
|
||||
["C:\\other\\a.ts", ["C:/project", "C:/other"], "C:/project"],
|
||||
["C:\\project-other\\a.ts", ["C:/project"], null],
|
||||
])("routes %s by workspace containment", async (file, projects, expected) => {
|
||||
useProject.setState({ project: { root: "C:/project", workspace: { projects } } as never });
|
||||
ctrl = new EditorController(createMemoryBackend({ [file]: "text\n" }));
|
||||
ctrl.start();
|
||||
await ctrl.openFile(file, { focus: false });
|
||||
expect(await ctrl.detachTab(...current())).toBe(true);
|
||||
expect(commands.windowOpenFileInNewWindow).toHaveBeenCalledWith(expected, file);
|
||||
});
|
||||
});
|
||||
@@ -198,6 +198,11 @@ function PaneView({ paneId }: { paneId: string }) {
|
||||
const pinnedRow =
|
||||
tabPrefs.pinnedSeparateRow && (vertical || !tabPrefs.multipleRows) && pinnedCount > 0 && pinnedCount < tabs.length;
|
||||
const dnd = useMemo(() => {
|
||||
// A tab released outside the window detaches into a forced new window.
|
||||
const dropOutside = (raw: string) => {
|
||||
const p = parseTabPayload(raw);
|
||||
if (p) void ctrl.detachTab(p.paneId, p.tabId);
|
||||
};
|
||||
const strip = (offset: number): TabsDnd => ({
|
||||
type: EDITOR_TAB_MIME,
|
||||
payload: (tabId) => JSON.stringify({ paneId, tabId }),
|
||||
@@ -205,6 +210,7 @@ function PaneView({ paneId }: { paneId: string }) {
|
||||
const p = parseTabPayload(raw);
|
||||
if (p) ctrl.moveTab(p.paneId, p.tabId, paneId, index + offset);
|
||||
},
|
||||
onDropOutside: dropOutside,
|
||||
});
|
||||
return { all: strip(0), rest: strip(pinnedCount) };
|
||||
}, [ctrl, paneId, pinnedCount]);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/** POSIX path helpers for backend paths (the backend always reports `/`-separated absolute paths). */
|
||||
import { isSameOrInside } from "@/platform/paths";
|
||||
export { isSameOrInside } from "@/platform/paths";
|
||||
|
||||
export function joinPath(dir: string, name: string): string {
|
||||
if (!name) return dir;
|
||||
@@ -15,11 +17,6 @@ export function nameOf(path: string): string {
|
||||
return i < 0 ? path : path.slice(i + 1);
|
||||
}
|
||||
|
||||
/** True when `path` equals `ancestor` or lies below it. */
|
||||
export function isSameOrInside(path: string, ancestor: string): boolean {
|
||||
return path === ancestor || path.startsWith(ancestor.endsWith("/") ? ancestor : ancestor + "/");
|
||||
}
|
||||
|
||||
export function isInside(path: string, ancestor: string): boolean {
|
||||
return path !== ancestor && isSameOrInside(path, ancestor);
|
||||
}
|
||||
|
||||
@@ -20,3 +20,10 @@ export function ipcPath(p: string): string {
|
||||
if (path.startsWith("\\\\?\\")) path = path.startsWith("\\\\?\\UNC\\") ? `\\\\${path.slice(8)}` : path.slice(4);
|
||||
return DRIVE.test(path) || NETWORK.test(path) ? path.replace(/\\/g, "/") : path;
|
||||
}
|
||||
|
||||
/** True when `path` equals `ancestor` or lies below it. */
|
||||
export function isSameOrInside(path: string, ancestor: string): boolean {
|
||||
path = ipcPath(path);
|
||||
ancestor = ipcPath(ancestor);
|
||||
return path === ancestor || path.startsWith(ancestor.endsWith("/") ? ancestor : ancestor + "/");
|
||||
}
|
||||
+23
-2
@@ -20,7 +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 { isOutsideWindow, registerDropTarget, startPointerDrag } from "./pointerDrag";
|
||||
import { T } from "./T";
|
||||
import styles from "./Tabs.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
@@ -53,6 +53,12 @@ export interface TabsDnd {
|
||||
type: string;
|
||||
payload: (id: string) => string;
|
||||
onDrop: (payload: string, index: number) => void;
|
||||
/**
|
||||
* A dragged tab released with no accepting target (the pointer left the
|
||||
* window): the editor uses it to detach the tab into a new window. Gets the
|
||||
* dragged tab's payload and the release point in client coordinates.
|
||||
*/
|
||||
onDropOutside?: (payload: string, x: number, y: number) => void;
|
||||
}
|
||||
|
||||
/** Insertion index for a drop at `x` given the tabs' horizontal extents. Exported for tests. */
|
||||
@@ -288,7 +294,22 @@ export function Tabs({
|
||||
{ type: dnd.type, payload: dnd.payload(item.id) },
|
||||
{
|
||||
onStart: () => setDragging(item.id),
|
||||
onEnd: () => setDragging(null),
|
||||
onEnd: (dragged, drop) => {
|
||||
setDragging(null);
|
||||
if (!dragged || !drop || drop.accepted) return;
|
||||
const outside = dnd.onDropOutside;
|
||||
if (!outside) return;
|
||||
const el = document.documentElement;
|
||||
const r = el.getBoundingClientRect();
|
||||
// The ghost (fixed, translated from the tab) never
|
||||
// covers the release point, so a miss outside the
|
||||
// viewport means the pointer left the window.
|
||||
const rect =
|
||||
r.width || r.height
|
||||
? r
|
||||
: { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight };
|
||||
if (isOutsideWindow(rect, drop.x, drop.y)) outside(dnd.payload(item.id), drop.x, drop.y);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isOutsideWindow } from "./pointerDrag";
|
||||
|
||||
const viewport = { left: 0, top: 0, right: 1280, bottom: 800 };
|
||||
|
||||
describe("isOutsideWindow", () => {
|
||||
it("treats the viewport interior as inside (issue #454: drag a tab out)", () => {
|
||||
expect(isOutsideWindow(viewport, 640, 400)).toBe(false);
|
||||
expect(isOutsideWindow(viewport, 0, 0)).toBe(false);
|
||||
expect(isOutsideWindow(viewport, 1279, 799)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats every side of the viewport as outside", () => {
|
||||
expect(isOutsideWindow(viewport, -1, 400)).toBe(true);
|
||||
expect(isOutsideWindow(viewport, 1280, 400)).toBe(true);
|
||||
expect(isOutsideWindow(viewport, 640, -1)).toBe(true);
|
||||
expect(isOutsideWindow(viewport, 640, 800)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,19 @@ export interface DragSession {
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether client point (`x`, `y`) is outside `rect` (the window's viewport):
|
||||
* a tab drag released there detaches into a new window instead of reordering.
|
||||
* Pure, so detach tests don't need a DOM.
|
||||
*/
|
||||
export function isOutsideWindow(
|
||||
rect: { left: number; top: number; right: number; bottom: number },
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean {
|
||||
return x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom;
|
||||
}
|
||||
|
||||
export function dragSession(data: DragData): DragSession {
|
||||
let current: DropTarget | null = null;
|
||||
const enter = (t: DropTarget | null) => {
|
||||
@@ -84,16 +97,25 @@ export function setDragRequiresAlt(fn: () => boolean) {
|
||||
}
|
||||
export const dragRequiresAlt = () => requiresAlt();
|
||||
|
||||
export interface PointerDrop {
|
||||
x: number;
|
||||
y: number;
|
||||
/** True when a registered target accepted the drop. */
|
||||
accepted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* cancels. `onEnd` gets whether the drag happened (to swallow the click) and,
|
||||
* when it did, where the pointer was released and whether a target accepted it
|
||||
* (an editor tab released outside the window detaches into a new window).
|
||||
*/
|
||||
export function startPointerDrag(
|
||||
e: { button: number; clientX: number; clientY: number; altKey: boolean },
|
||||
source: HTMLElement,
|
||||
data: DragData,
|
||||
opts: { onStart?: () => void; onEnd?: (dragged: boolean) => void } = {},
|
||||
opts: { onStart?: () => void; onEnd?: (dragged: boolean, drop?: PointerDrop) => void } = {},
|
||||
) {
|
||||
if (e.button !== 0 || (requiresAlt() && !e.altKey)) return;
|
||||
const start = { x: e.clientX, y: e.clientY };
|
||||
@@ -124,8 +146,12 @@ export function startPointerDrag(
|
||||
const onUp = (ev: PointerEvent) => {
|
||||
const dragged = !!ghost;
|
||||
cleanup();
|
||||
if (dragged) session.drop(ev.clientX, ev.clientY);
|
||||
opts.onEnd?.(dragged);
|
||||
if (!dragged) {
|
||||
opts.onEnd?.(false);
|
||||
return;
|
||||
}
|
||||
const accepted = session.drop(ev.clientX, ev.clientY);
|
||||
opts.onEnd?.(true, { x: ev.clientX, y: ev.clientY, accepted });
|
||||
};
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
if (ev.key !== "Escape" || !ghost) return;
|
||||
|
||||
Reference in new issue
Заблокувати користувача