From 178fc9eaba0db1964fe848fab64e657f6a9e846f Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:24:31 +0800 Subject: [PATCH] feat(editor): move preview layout controls into the tab strip (#554) Markdown layout controls occupied a permanent bordered popup over the document. - Move them into the editor tab strip with layout icons. When tabs are hidden, including Distraction Free Mode, show one floating toolbar that appears on mouse activity and fades after inactivity. - Use the same floating toolbar for the HTML browser controls. - Keep keyboard access: hold the toolbar while focused, return focus on Escape, and respect reduced motion. Add theme tokens with their import, template and reference entries. --- docs/plugins/themes.md | 3 +- scripts/theme/intellij.ts | 1 + src/editor/core/view/EditorArea.tsx | 20 ++- .../core/view/editorLayoutActions.test.tsx | 114 ++++++++++++++++++ src/features/viewers/index.ts | 8 +- .../viewers/markdown/Markdown.module.css | 20 +-- .../viewers/markdown/MarkdownEditor.tsx | 48 ++++---- .../markdown/MarkdownLayoutActions.tsx | 39 ++++++ src/features/web/Web.module.css | 24 +--- src/features/web/WebEditor.tsx | 43 ++++--- src/styles/tokens.css | 13 ++ src/ui/FloatingToolbar.module.css | 27 +++++ src/ui/FloatingToolbar.test.tsx | 89 ++++++++++++++ src/ui/FloatingToolbar.tsx | 81 +++++++++++++ src/ui/expui/general/editorOnly.svg | 7 ++ src/ui/expui/general/editorOnly_dark.svg | 7 ++ src/ui/expui/general/editorPreview.svg | 8 ++ src/ui/expui/general/editorPreview_dark.svg | 8 ++ src/ui/expui/general/previewOnly.svg | 6 + src/ui/expui/general/previewOnly_dark.svg | 6 + src/ui/icons.tsx | 3 + src/ui/index.ts | 1 + src/workbench/contributions.ts | 4 +- .../themes/harbor-night.theme.json | 1 + 24 files changed, 491 insertions(+), 90 deletions(-) create mode 100644 src/editor/core/view/editorLayoutActions.test.tsx create mode 100644 src/features/viewers/markdown/MarkdownLayoutActions.tsx create mode 100644 src/ui/FloatingToolbar.module.css create mode 100644 src/ui/FloatingToolbar.test.tsx create mode 100644 src/ui/FloatingToolbar.tsx create mode 100644 src/ui/expui/general/editorOnly.svg create mode 100644 src/ui/expui/general/editorOnly_dark.svg create mode 100644 src/ui/expui/general/editorPreview.svg create mode 100644 src/ui/expui/general/editorPreview_dark.svg create mode 100644 src/ui/expui/general/previewOnly.svg create mode 100644 src/ui/expui/general/previewOnly_dark.svg diff --git a/docs/plugins/themes.md b/docs/plugins/themes.md index 33ce8c2f9..6b038fc5c 100644 --- a/docs/plugins/themes.md +++ b/docs/plugins/themes.md @@ -295,7 +295,7 @@ a token. -222 themeable tokens: 116 in the UI theme file, 106 in the editor colour scheme file. +223 themeable tokens: 117 in the UI theme file, 106 in the editor colour scheme file. ### Surfaces (UI theme) @@ -304,6 +304,7 @@ a token. | `--bg-app` | `#191a1c` | `#ffffff` | | `--bg-elev` | `#212326` | `#f7f8f9` | | `--bg-popup` | `#26282c` | `#ffffff` | +| `--bg-floating-toolbar` | `#33353b` | `#ffffff` | | `--bg-hover` | `#2e2f30` | `#ededed` | | `--bg-selected` | `#2a4371` | `#d0dffe` | | `--bg-selected-strong` | `#2e4d89` | `#bdd3ff` | diff --git a/scripts/theme/intellij.ts b/scripts/theme/intellij.ts index 1169a50ac..407f2ca06 100644 --- a/scripts/theme/intellij.ts +++ b/scripts/theme/intellij.ts @@ -14,6 +14,7 @@ export const UI_KEYS: Record = { "--bg-island": ["ToolWindow.background", "Panel.background"], "--bg-elev": ["ToolWindow.background", "SidePanel.background"], "--bg-popup": ["Popup.background", "PopupMenu.background"], + "--bg-floating-toolbar": ["Toolbar.Floating.background"], "--bg-backdrop": ["Popup.background"], "--bg-hover": ["List.hoverBackground", "Tree.hoverBackground"], "--bg-selected": ["List.selectionBackground", "Tree.selectionBackground"], diff --git a/src/editor/core/view/EditorArea.tsx b/src/editor/core/view/EditorArea.tsx index e91bebf39..aa67dec75 100644 --- a/src/editor/core/view/EditorArea.tsx +++ b/src/editor/core/view/EditorArea.tsx @@ -257,6 +257,7 @@ function PaneView({ paneId }: { paneId: string }) { const onFocus = () => { setContext("editorFocus", true); if (!isActivePane) useTabs.setState((s) => ({ ...s, activePane: paneId }), true); + ctrl.publishContext(); }; const onBlur = (e: FocusEvent) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setContext("editorFocus", false); @@ -298,7 +299,7 @@ function PaneView({ paneId }: { paneId: string }) { trailing={trailing} /> ); - const actions = active ? : undefined; + const actions = active && !hidden ? : undefined; return (
- {active ? : } + {active ? : } {dropZone &&
@@ -344,7 +345,7 @@ function virtualTooltip(input: EditorInput): string | null { return [d?.description, input.path].filter(Boolean).join("\n"); } -function PaneContent({ paneId, tab }: { paneId: string; tab: Tab }) { +function PaneContent({ paneId, tab, tabsVisible }: { paneId: string; tab: Tab; tabsVisible: boolean }) { const kinds = useContributions((s) => s.editorKinds); const crumbs = !useDistractionFree((s) => s.active); const wrappers = useTextEditorWrappers((s) => s.wrappers); @@ -362,7 +363,12 @@ function PaneContent({ paneId, tab }: { paneId: string; tab: Tab }) { {crumbs && } {wrapper ? ( - + {host} ) : ( @@ -385,10 +391,16 @@ function PaneContent({ paneId, tab }: { paneId: string; tab: Tab }) { /** Actions at the right end of the tab row (IDEA: split, tab actions ⋮). */ function EditorTabActions({ paneId, tab }: { paneId: string; tab: Tab }) { const ctrl = editorController(); + const wrappers = useTextEditorWrappers((s) => s.wrappers); + const LayoutActions = + tab.input.kind === "file" && tab.input.path + ? textEditorWrapperFor(wrappers, tab.input.path)?.tabActions + : undefined; const [open, setOpen] = useState(false); const moreRef = useRef(null); return (
+ {LayoutActions && } ({ + ...(await original()), + editorController: () => ({ publishContext: vi.fn(), tabMenu: () => [] }), +})); +vi.mock("./TextEditorHost", () => ({ TextEditorHost: () => })); +vi.mock("./FindBar", () => ({ FindBar: () => null })); +vi.mock("./Banner", () => ({ EditorBanners: () => null, VirtualBanner: () => null })); +vi.mock("./StatusItems", () => ({ CodeBreadcrumbs: () => null })); + +const md = (id: string, path: string): Tab => ({ + id, + input: { kind: "file", key: path, path }, + preview: false, + pinned: false, +}); +const first = md("t1", "/p/first.md"); +const second = md("t2", "/p/second.md"); +let disposers: Array<() => void> = []; +const originalTabs = useTabs.getState(); +const originalProject = useProject.getState(); + +beforeEach(() => { + useMarkdownModes.setState({ byPath: {}, fallback: "editor" }); + useTabs.setState({ + layout: { kind: "pane", paneId: "p1" }, + panes: { p1: { id: "p1", tabs: [first], active: first.id, mru: [first.id] } }, + activePane: "p1", + closed: [], + }); + disposers = [ + registerTextEditorWrapper({ + id: "test.markdown", + matches: (path) => path.endsWith(".md"), + component: MarkdownEditor, + tabActions: MarkdownLayoutActions, + }), + registerActions([ + { id: "markdown.editorOnly", title: "Editor Only", run: ({ arg }) => setMarkdownMode(String(arg), "editor") }, + { + id: "markdown.editorAndPreview", + title: "Editor and Preview", + run: ({ arg }) => setMarkdownMode(String(arg), "split"), + }, + { id: "markdown.previewOnly", title: "Preview Only", run: ({ arg }) => setMarkdownMode(String(arg), "preview") }, + ]), + ]; +}); +afterEach(() => { + cleanup(); + disposers.reverse().forEach((dispose) => dispose()); + useTabs.setState(originalTabs, true); + useProject.setState(originalProject, true); + useDistractionFree.setState({ active: false }); +}); + +describe("Markdown layout controls", () => { + it.each(["top", "bottom", "left", "right"])("uses the tab strip with %s tabs", (placement) => { + useProject.setState({ settings: { editor: { tabs: { placement } } } as never }); + render(); + const toolbar = screen.getByRole("toolbar", { name: "Markdown editor layout" }); + expect(toolbar.closest('[role="tabpanel"]')).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Preview Only" })); + expect(screen.getByRole("document", { name: "Markdown preview" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Preview Only" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("textbox", { name: "Source" })).toBeInTheDocument(); + }); + + it.each(["hidden", "distraction-free"])("uses one floating fallback when tabs are %s", (reason) => { + useProject.setState({ + settings: { editor: { tabs: { placement: reason === "hidden" ? "none" : "top" } } } as never, + }); + useDistractionFree.setState({ active: reason === "distraction-free" }); + render(); + expect(screen.getByRole("toolbar", { name: "Markdown editor layout" }).closest('[role="tabpanel"]')).not.toBeNull(); + }); + + it("routes each pane's layout buttons to that pane's file", () => { + useProject.setState({ settings: null }); + useTabs.setState({ + layout: { + kind: "split", + id: "split", + dir: "row", + children: [ + { kind: "pane", paneId: "p1" }, + { kind: "pane", paneId: "p2" }, + ], + }, + panes: { + p1: { id: "p1", tabs: [first], active: first.id, mru: [first.id] }, + p2: { id: "p2", tabs: [second], active: second.id, mru: [second.id] }, + }, + }); + render(); + fireEvent.click(screen.getAllByRole("button", { name: "Preview Only" })[1]!); + expect(useMarkdownModes.getState().byPath[first.input.path!]).toBeUndefined(); + expect(useMarkdownModes.getState().byPath[second.input.path!]).toBe("preview"); + act(() => useDistractionFree.setState({ active: true })); + expect(screen.getAllByRole("toolbar", { name: "Markdown editor layout" })).toHaveLength(2); + }); +}); diff --git a/src/features/viewers/index.ts b/src/features/viewers/index.ts index 215d01252..92ac8c156 100644 --- a/src/features/viewers/index.ts +++ b/src/features/viewers/index.ts @@ -28,6 +28,7 @@ import { ImageViewer, toggleChessboard, useImageViewers } from "./ImageViewer"; import { HexViewer } from "./HexViewer"; import { registerMarkdownLiveSource } from "./markdown/live"; import { setMarkdownMode, type MarkdownMode } from "./markdown/modes"; +import { MarkdownLayoutActions } from "./markdown/MarkdownLayoutActions"; const titleOf = (input: EditorInput) => basename(input.path ?? input.key); const iconOf = (input: EditorInput) => fileIconName(titleOf(input)); @@ -70,9 +71,9 @@ function markdownActions(): Action[] { }, }); return [ - mode("markdown.editorOnly", "Editor Only", "editor", "edit"), - mode("markdown.editorAndPreview", "Editor and Preview", "split", "split"), - mode("markdown.previewOnly", "Preview Only", "preview", "eye"), + mode("markdown.editorOnly", "Editor Only", "editor", "editor-only"), + mode("markdown.editorAndPreview", "Editor and Preview", "split", "editor-preview"), + mode("markdown.previewOnly", "Preview Only", "preview", "preview-only"), ]; } @@ -97,6 +98,7 @@ export function activate(): () => void { id: "viewers.markdown", matches: isMarkdownPath, component: lazyComponent(() => import("./markdown/MarkdownEditor").then((m) => m.MarkdownEditor)), + tabActions: MarkdownLayoutActions, }), registerMarkdownLiveSource(), registerActions([...imageActions(), ...markdownActions()]), diff --git a/src/features/viewers/markdown/Markdown.module.css b/src/features/viewers/markdown/Markdown.module.css index f36111227..65891fd9f 100644 --- a/src/features/viewers/markdown/Markdown.module.css +++ b/src/features/viewers/markdown/Markdown.module.css @@ -18,23 +18,13 @@ border-right: 1px solid var(--border-editor-tabs, var(--border)); } -.modeBar { - position: absolute; - top: 6px; - right: 20px; - z-index: 5; +.layoutActions { display: flex; - gap: 1px; - padding: 2px; - border: 1px solid var(--border-popup); - border-radius: var(--radius-toolbar-button); - background: var(--bg-popup); - box-shadow: var(--shadow-popup); - opacity: 0.85; + align-items: center; } -.modeBar:hover, -.modeBar:focus-within { - opacity: 1; +.layoutActions > button { + width: var(--h-floating-toolbar-button); + height: var(--h-floating-toolbar-button); } .preview { diff --git a/src/features/viewers/markdown/MarkdownEditor.tsx b/src/features/viewers/markdown/MarkdownEditor.tsx index d76ef6da6..299bfaa97 100644 --- a/src/features/viewers/markdown/MarkdownEditor.tsx +++ b/src/features/viewers/markdown/MarkdownEditor.tsx @@ -1,6 +1,6 @@ /** * Text editor wrapper of Markdown files: the editor, a live preview, or both - * side by side, switched from the floating toolbar at the top right. The + * side by side, switched from the tab strip or a floating toolbar. The * preview follows the editor's scroll position in the split layout. */ import { @@ -19,10 +19,11 @@ import clsx from "clsx"; import { call, commands, isTauri } from "@/platform/ipc"; import { maybeEditorService } from "@/workbench/contributions"; import { highlightInto } from "@/editor/lsp/markdown"; -import { IconButton, selectableText } from "@/ui"; +import { FloatingToolbar, selectableText } from "@/ui"; import { useLiveMarkdown } from "./live"; import { hasMermaid, MERMAID_SELECTOR, renderMermaidIn, subscribeTheme, themeKey } from "./mermaid"; import { modeOf, useMarkdownModes } from "./modes"; +import { MarkdownLayoutActions } from "./MarkdownLayoutActions"; import { ANCHOR_PREFIX, previewOffsetForLine, renderMarkdown, resolveLink, slugify } from "./render"; import styles from "./Markdown.module.css"; import { tx } from "@/platform/i18n"; @@ -47,35 +48,28 @@ function openExternal(url: string) { else window.open(url, "_blank", "noopener"); } -export function MarkdownEditor({ path, children }: { path: string; tabId: string; children: ReactNode }) { +export function MarkdownEditor({ + path, + tabId, + children, + actionsInTabs = false, +}: { + path: string; + tabId: string; + children: ReactNode; + actionsInTabs?: boolean; +}) { const mode = useMarkdownModes((s) => modeOf(s, path)); + const ref = useRef(null); return ( -
+
{children}
{mode !== "editor" && } -
- - - -
+ {!actionsInTabs && ( + + + + )}
); } diff --git a/src/features/viewers/markdown/MarkdownLayoutActions.tsx b/src/features/viewers/markdown/MarkdownLayoutActions.tsx new file mode 100644 index 000000000..aba024aaa --- /dev/null +++ b/src/features/viewers/markdown/MarkdownLayoutActions.tsx @@ -0,0 +1,39 @@ +import { IconButton } from "@/ui"; +import { tx } from "@/platform/i18n"; +import { modeOf, useMarkdownModes } from "./modes"; +import styles from "./Markdown.module.css"; + +export function MarkdownLayoutActions({ path }: { path: string; tabId: string }) { + const mode = useMarkdownModes((s) => modeOf(s, path)); + return ( +
+ + + +
+ ); +} diff --git a/src/features/web/Web.module.css b/src/features/web/Web.module.css index 22de7dd5c..061d78660 100644 --- a/src/features/web/Web.module.css +++ b/src/features/web/Web.module.css @@ -15,28 +15,12 @@ border-right: 1px solid var(--border-editor-tabs, var(--border)); } -/* Below the inspection widget; shown while the pointer is over the editor, like IDEA. */ -.bar { - position: absolute; - top: 32px; - right: 20px; - z-index: 5; +.actions { display: flex; - gap: 1px; - padding: 2px; - border: 1px solid var(--border-popup); - border-radius: var(--radius-toolbar-button); - background: var(--bg-popup); - box-shadow: var(--shadow-popup); - opacity: 0; - transition: opacity 120ms ease; + align-items: center; } -.editor:hover .bar { - opacity: 0.85; -} -.bar:hover, -.bar:focus-within { - opacity: 1; +.browserBar { + top: var(--floating-toolbar-browser-top); } .preview { diff --git a/src/features/web/WebEditor.tsx b/src/features/web/WebEditor.tsx index 9d272c95e..df7cd9838 100644 --- a/src/features/web/WebEditor.tsx +++ b/src/features/web/WebEditor.tsx @@ -3,11 +3,11 @@ * floating "open in browser" toolbar) and the built-in preview beside the * editor, served by the built-in server and reloaded when files are saved. */ -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import clsx from "clsx"; import { onIdeEvent } from "@/platform/ipc"; import { useProject } from "@/platform/project"; -import { IconButton } from "@/ui"; +import { FloatingToolbar, IconButton } from "@/ui"; import { browserIcon, openInBrowser, useBrowsers, useWebPreview, webUrl } from "./browsers"; import styles from "./Web.module.css"; import { tx } from "@/platform/i18n"; @@ -17,29 +17,34 @@ const RELOAD_DELAY = 250; export function WebEditor({ path, children }: { path: string; tabId: string; children: ReactNode }) { const preview = useWebPreview((s) => !!s[path]); const browsers = useBrowsers((s) => s.list); + const ref = useRef(null); return (
-
+
{children} -
- - {browsers.map((b) => ( + +
void openInBrowser(path, b.id)} + iconSize={16} + action="web.togglePreview" + actionArg={path} + pressed={preview} + title="Built-in Preview" /> - ))} -
+ {browsers.map((b) => ( + void openInBrowser(path, b.id)} + /> + ))} +
+
{preview && }
diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 281aa7446..66ffc23e2 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -19,6 +19,7 @@ html { --bg-app: #191a1c; --bg-elev: #212326; --bg-popup: #26282c; + --bg-floating-toolbar: #33353b; --bg-hover: #2e2f30; --bg-selected: #2a4371; --bg-selected-strong: #2e4d89; @@ -237,6 +238,14 @@ html { --radius-control: 4px; --radius-tab: 6px; --radius-toolbar-button: 6px; + --radius-floating-toolbar: 3px; + --h-floating-toolbar-button: 22px; + --floating-toolbar-top: 25px; + --floating-toolbar-browser-top: 32px; + --floating-toolbar-right: 20px; + --floating-toolbar-opacity: 55%; + --dur-floating-toolbar-show: 500ms; + --dur-floating-toolbar-hide: 1000ms; --radius-tooltip: 6px; --shadow-popup: 0 6px 20px rgba(0, 0, 0, 0.45); @@ -400,6 +409,7 @@ html { --bg-app: #ffffff; --bg-elev: #f7f8f9; --bg-popup: #ffffff; + --bg-floating-toolbar: #ffffff; --bg-hover: #ededed; --bg-selected: #d0dffe; --bg-selected-strong: #bdd3ff; @@ -660,6 +670,8 @@ html { --bg-app: #000000; --bg-elev: #0e0f11; --bg-popup: #111214; + --bg-floating-toolbar: #1a1b1e; + --floating-toolbar-opacity: 100%; --bg-hover: #1c1e22; --bg-selected: #1f3a73; --bg-selected-strong: #2a4b8f; @@ -914,6 +926,7 @@ html { --bg-inlay: var(--palette-panel); --bg-fold-placeholder: var(--palette-panel); --bg-popup: var(--palette-popup); + --bg-floating-toolbar: var(--palette-popup); --bg-tooltip: var(--palette-popup); --bg-hover: var(--palette-hover); --bg-control-hover: var(--palette-hover); diff --git a/src/ui/FloatingToolbar.module.css b/src/ui/FloatingToolbar.module.css new file mode 100644 index 000000000..1f87b462d --- /dev/null +++ b/src/ui/FloatingToolbar.module.css @@ -0,0 +1,27 @@ +.toolbar { + position: absolute; + top: var(--floating-toolbar-top); + right: var(--floating-toolbar-right); + z-index: 5; + display: flex; + padding: 2px; + border-radius: var(--radius-floating-toolbar); + background: color-mix(in srgb, var(--bg-floating-toolbar) var(--floating-toolbar-opacity), transparent); + opacity: 0; + pointer-events: none; + transition: opacity var(--dur-floating-toolbar-hide) ease; +} +.toolbar button { + width: var(--h-floating-toolbar-button); + height: var(--h-floating-toolbar-button); +} +.visible { + opacity: 1; + pointer-events: auto; + transition-duration: var(--dur-floating-toolbar-show); +} +@media (prefers-reduced-motion: reduce) { + .toolbar { + transition: none; + } +} diff --git a/src/ui/FloatingToolbar.test.tsx b/src/ui/FloatingToolbar.test.tsx new file mode 100644 index 000000000..ee9fb1c1b --- /dev/null +++ b/src/ui/FloatingToolbar.test.tsx @@ -0,0 +1,89 @@ +import { useRef } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { FloatingToolbar } from "./FloatingToolbar"; +import styles from "./FloatingToolbar.module.css"; + +function Fixture({ hideOnTyping = true }: { hideOnTyping?: boolean }) { + const ref = useRef(null); + return ( +
+ + + + +
+ ); +} + +describe("editor floating toolbar", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("appears on mouse activity, stays under the pointer, then fades after inactivity", () => { + render(); + const editor = screen.getByTestId("editor"); + const button = screen.getByRole("button", { name: "Preview" }); + const toolbar = button.parentElement!; + expect(toolbar).not.toHaveClass(styles.visible!); + fireEvent.pointerMove(editor); + expect(toolbar).toHaveClass(styles.visible!); + act(() => vi.advanceTimersByTime(1000)); + fireEvent.pointerMove(button); + act(() => vi.advanceTimersByTime(5000)); + expect(toolbar).toHaveClass(styles.visible!); + fireEvent.pointerMove(editor); + act(() => vi.advanceTimersByTime(1400)); + expect(toolbar).not.toHaveClass(styles.visible!); + }); + + it("hides while editing but remains available to keyboard users", () => { + render(); + const source = screen.getByRole("textbox", { name: "Source" }); + const button = screen.getByRole("button", { name: "Preview" }); + const toolbar = button.parentElement!; + fireEvent.pointerMove(source); + fireEvent.keyDown(source, { key: "a" }); + expect(toolbar).not.toHaveClass(styles.visible!); + act(() => button.focus()); + fireEvent.pointerLeave(screen.getByTestId("editor")); + act(() => vi.advanceTimersByTime(5000)); + expect(toolbar).toHaveClass(styles.visible!); + act(() => source.focus()); + expect(toolbar).not.toHaveClass(styles.visible!); + }); + + it("returns focus to the editor when Escape dismisses a focused toolbar", () => { + render(); + const source = screen.getByRole("textbox", { name: "Source" }); + const button = screen.getByRole("button", { name: "Preview" }); + act(() => source.focus()); + act(() => button.focus()); + fireEvent.keyDown(button, { key: "Escape" }); + expect(source).toHaveFocus(); + expect(button.parentElement).not.toHaveClass(styles.visible!); + }); + + it("lets browser controls stay visible during typing and dismisses them with Escape", () => { + render(); + const source = screen.getByRole("textbox", { name: "Source" }); + const toolbar = screen.getByRole("button", { name: "Preview" }).parentElement!; + fireEvent.pointerMove(source); + fireEvent.keyDown(source, { key: "a" }); + expect(toolbar).toHaveClass(styles.visible!); + fireEvent.keyDown(source, { key: "Escape" }); + expect(toolbar).not.toHaveClass(styles.visible!); + }); + + it("releases timers when the editor closes", () => { + const { unmount } = render(); + const before = vi.getTimerCount(); + fireEvent.pointerMove(screen.getByTestId("editor")); + expect(vi.getTimerCount()).toBe(before + 1); + unmount(); + expect(vi.getTimerCount()).toBe(before); + }); +}); diff --git a/src/ui/FloatingToolbar.tsx b/src/ui/FloatingToolbar.tsx new file mode 100644 index 000000000..6748ea6c7 --- /dev/null +++ b/src/ui/FloatingToolbar.tsx @@ -0,0 +1,81 @@ +import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react"; +import clsx from "clsx"; +import styles from "./FloatingToolbar.module.css"; + +/** IDEA's editor overlays: mouse activity reveals them, keyboard focus keeps them available. */ +export function FloatingToolbar({ + targetRef, + children, + className, + hideOnTyping = true, + retentionMs = 1400, +}: { + targetRef: RefObject; + children: ReactNode; + className?: string; + hideOnTyping?: boolean; + retentionMs?: number; +}) { + const ref = useRef(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + const target = targetRef.current; + const toolbar = ref.current; + if (!target || !toolbar) return; + let timer: ReturnType | undefined; + let returnFocus: HTMLElement | null = null; + const clear = () => clearTimeout(timer); + const focused = () => toolbar.contains(document.activeElement); + const hide = () => { + clear(); + if (!focused()) setVisible(false); + }; + const move = (event: PointerEvent) => { + clear(); + setVisible(true); + if (!toolbar.contains(event.target as Node) && !focused()) timer = setTimeout(hide, retentionMs); + }; + const key = (event: KeyboardEvent) => { + if (event.key === "Escape" && focused()) { + if (returnFocus?.isConnected) returnFocus.focus(); + else (document.activeElement as HTMLElement | null)?.blur(); + } + if (event.key === "Escape" || (hideOnTyping && !toolbar.contains(event.target as Node))) hide(); + }; + const focus = (event: FocusEvent) => { + if (focused()) { + if (event.relatedTarget instanceof HTMLElement && !toolbar.contains(event.relatedTarget)) { + returnFocus = event.relatedTarget; + } + clear(); + setVisible(true); + } + }; + const blur = (event: FocusEvent) => { + if (!toolbar.contains(event.relatedTarget as Node | null)) { + clear(); + setVisible(false); + } + }; + target.addEventListener("pointermove", move); + target.addEventListener("pointerleave", hide); + target.addEventListener("keydown", key); + toolbar.addEventListener("focusin", focus); + toolbar.addEventListener("focusout", blur); + return () => { + clear(); + target.removeEventListener("pointermove", move); + target.removeEventListener("pointerleave", hide); + target.removeEventListener("keydown", key); + toolbar.removeEventListener("focusin", focus); + toolbar.removeEventListener("focusout", blur); + }; + }, [targetRef, hideOnTyping, retentionMs]); + + return ( +
+ {children} +
+ ); +} diff --git a/src/ui/expui/general/editorOnly.svg b/src/ui/expui/general/editorOnly.svg new file mode 100644 index 000000000..cbc4fec09 --- /dev/null +++ b/src/ui/expui/general/editorOnly.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/general/editorOnly_dark.svg b/src/ui/expui/general/editorOnly_dark.svg new file mode 100644 index 000000000..00fc88b4b --- /dev/null +++ b/src/ui/expui/general/editorOnly_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/general/editorPreview.svg b/src/ui/expui/general/editorPreview.svg new file mode 100644 index 000000000..710c1934a --- /dev/null +++ b/src/ui/expui/general/editorPreview.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/ui/expui/general/editorPreview_dark.svg b/src/ui/expui/general/editorPreview_dark.svg new file mode 100644 index 000000000..5bc756438 --- /dev/null +++ b/src/ui/expui/general/editorPreview_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/ui/expui/general/previewOnly.svg b/src/ui/expui/general/previewOnly.svg new file mode 100644 index 000000000..98e3a291a --- /dev/null +++ b/src/ui/expui/general/previewOnly.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/expui/general/previewOnly_dark.svg b/src/ui/expui/general/previewOnly_dark.svg new file mode 100644 index 000000000..509f58719 --- /dev/null +++ b/src/ui/expui/general/previewOnly_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 6b6c329b6..7d5a8e041 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -221,6 +221,9 @@ const ICONS = { layout: "general/layout", split: "general/splitVertically", "split-horizontal": "general/splitHorizontally", + "editor-only": "general/editorOnly", + "editor-preview": "general/editorPreview", + "preview-only": "general/previewOnly", maximize: "image/fitContent", "zoom-in": "openidea/general/zoomIn", "zoom-out": "openidea/general/zoomOut", diff --git a/src/ui/index.ts b/src/ui/index.ts index 223668cee..21427a32f 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -1,5 +1,6 @@ export * from "./icons"; export * from "./Button"; +export * from "./FloatingToolbar"; export * from "./Kbd"; export * from "./Tooltip"; export * from "./Input"; diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 98e80ef09..9d54b12d0 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -432,7 +432,9 @@ export async function resolveFileEditor(path: string): Promise; + component: ComponentType<{ path: string; tabId: string; children: ReactNode; actionsInTabs?: boolean }>; + /** Layout controls in the tab strip; the wrapper supplies a floating fallback when tabs are hidden. */ + tabActions?: ComponentType<{ path: string; tabId: string }>; } export const useTextEditorWrappers = create<{ wrappers: Map }>(() => ({ diff --git a/templates/theme-plugin/themes/harbor-night.theme.json b/templates/theme-plugin/themes/harbor-night.theme.json index 85401e892..a8f90cf3a 100644 --- a/templates/theme-plugin/themes/harbor-night.theme.json +++ b/templates/theme-plugin/themes/harbor-night.theme.json @@ -11,6 +11,7 @@ "--bg-app": "#14171c", "--bg-elev": "#1b2027", "--bg-popup": "#1f252e", + "--bg-floating-toolbar": "#1f252e", "--bg-hover": "#262c33", "--bg-selected": "#22484b", "--bg-selected-strong": "#285a5d",