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 33ce8c2f..6b038fc5 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 1169a50a..407f2ca0 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 e91bebf3..aa67dec7 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 215d0125..92ac8c15 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 f3611122..65891fd9 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 d76ef6da..299bfaa9 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 00000000..aba024aa --- /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 22de7dd5..061d7866 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 9d272c95..df7cd983 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 281aa744..66ffc23e 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 00000000..1f87b462 --- /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 00000000..ee9fb1c1 --- /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 00000000..6748ea6c --- /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 00000000..cbc4fec0 --- /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 00000000..00fc88b4 --- /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 00000000..710c1934 --- /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 00000000..5bc75643 --- /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 00000000..98e3a291 --- /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 00000000..509f5871 --- /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 6b6c329b..7d5a8e04 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 223668ce..21427a32 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 98e80ef0..9d54b12d 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 85401e89..a8f90cf3 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",