From 2754cf327ffaa4de9647821324d19ef49e52c3b6 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Sat, 31 Oct 2026 00:57:53 +0800 Subject: [PATCH] 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 <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/window.rs | 13 ++ src-tauri/src/windows.rs | 10 ++ src/bindings.ts | 6 + src/editor/core/controller.ts | 50 +++++++- src/editor/core/detachTab.test.ts | 179 ++++++++++++++++++++++++++++ src/editor/core/view/EditorArea.tsx | 6 + src/features/project-tree/paths.ts | 7 +- src/platform/paths.ts | 7 ++ src/ui/Tabs.tsx | 25 +++- src/ui/pointerDrag.test.ts | 19 +++ src/ui/pointerDrag.ts | 34 +++++- 12 files changed, 345 insertions(+), 12 deletions(-) create mode 100644 src/editor/core/detachTab.test.ts create mode 100644 src/ui/pointerDrag.test.ts diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 59cbd746a..9ff891c85 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -340,6 +340,7 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 23f4b0303..0fbbd82b7 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -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, + file: PathBuf, +) -> IdeResult { + 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] diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index c0076a1f7..78381f709 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -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, file: PathBuf) -> IdeResult { + 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) -> Vec { diff --git a/src/bindings.ts b/src/bindings.ts index ac992e0c0..4f0982ad4 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -686,6 +686,12 @@ export const commands = { * itself, in LightEdit. */ windowOpenFiles: (files: string[]) => typedError(__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(__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. diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 9188fcb87..7bf9e0282 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -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 } = {}, ): Promise { 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 { + 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 ? ([ diff --git a/src/editor/core/detachTab.test.ts b/src/editor/core/detachTab.test.ts new file mode 100644 index 000000000..e919c6dd0 --- /dev/null +++ b/src/editor/core/detachTab.test.ts @@ -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()), + 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((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); + }); +}); diff --git a/src/editor/core/view/EditorArea.tsx b/src/editor/core/view/EditorArea.tsx index f948b322e..8b65856d1 100644 --- a/src/editor/core/view/EditorArea.tsx +++ b/src/editor/core/view/EditorArea.tsx @@ -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]); diff --git a/src/features/project-tree/paths.ts b/src/features/project-tree/paths.ts index 6bd806d87..684194671 100644 --- a/src/features/project-tree/paths.ts +++ b/src/features/project-tree/paths.ts @@ -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); } diff --git a/src/platform/paths.ts b/src/platform/paths.ts index 6c5d9a943..0e3cc6d69 100644 --- a/src/platform/paths.ts +++ b/src/platform/paths.ts @@ -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 + "/"); +} diff --git a/src/ui/Tabs.tsx b/src/ui/Tabs.tsx index 5f9cbe30a..ad4e4b12c 100644 --- a/src/ui/Tabs.tsx +++ b/src/ui/Tabs.tsx @@ -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); + }, }, ); } diff --git a/src/ui/pointerDrag.test.ts b/src/ui/pointerDrag.test.ts new file mode 100644 index 000000000..12b68a7ed --- /dev/null +++ b/src/ui/pointerDrag.test.ts @@ -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); + }); +}); diff --git a/src/ui/pointerDrag.ts b/src/ui/pointerDrag.ts index 4edb0dc14..7f53b701a 100644 --- a/src/ui/pointerDrag.ts +++ b/src/ui/pointerDrag.ts @@ -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;