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",