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.
This commit is contained in:
l0ng-ai
2026-10-31 20:24:31 +08:00
committed by GitHub
parent 17e3cd2b39
commit 178fc9eaba
24 changed files with 491 additions and 90 deletions
+2 -1
View File
@@ -295,7 +295,7 @@ a token.
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
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` |
+1
View File
@@ -14,6 +14,7 @@ export const UI_KEYS: Record<string, string[]> = {
"--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"],
+16 -4
View File
@@ -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 ? <EditorTabActions paneId={paneId} tab={active} /> : undefined;
const actions = active && !hidden ? <EditorTabActions paneId={paneId} tab={active} /> : undefined;
return (
<section
@@ -330,7 +331,7 @@ function PaneView({ paneId }: { paneId: string }) {
aria-labelledby={active ? `editor-${paneId}-tab-${active.id}` : undefined}
ref={contentRef}
>
{active ? <PaneContent paneId={paneId} tab={active} /> : <EmptyEditor />}
{active ? <PaneContent paneId={paneId} tab={active} tabsVisible={!hidden} /> : <EmptyEditor />}
{dropZone && <div className={clsx(styles.dropZone, styles[`drop-${dropZone}`])} aria-hidden="true" />}
</div>
</section>
@@ -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 }) {
<FindBar paneId={paneId} />
{crumbs && <CodeBreadcrumbs path={tab.input.path!} tabId={tab.id} position="top" />}
{wrapper ? (
<wrapper.component key={tab.id} path={tab.input.path} tabId={tab.id}>
<wrapper.component
key={tab.id}
path={tab.input.path}
tabId={tab.id}
actionsInTabs={tabsVisible && !!wrapper.tabActions}
>
{host}
</wrapper.component>
) : (
@@ -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<HTMLButtonElement>(null);
return (
<div className={styles.tabActions}>
{LayoutActions && <LayoutActions path={tab.input.path!} tabId={tab.id} />}
<IconButton
icon="split"
title="Split Right"
@@ -0,0 +1,114 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { registerTextEditorWrapper } from "@/workbench/contributions";
import { useDistractionFree } from "@/workbench/distractionFree";
import { MarkdownEditor } from "@/features/viewers/markdown/MarkdownEditor";
import { MarkdownLayoutActions } from "@/features/viewers/markdown/MarkdownLayoutActions";
import { setMarkdownMode, useMarkdownModes } from "@/features/viewers/markdown/modes";
import { useTabs } from "../controller";
import type { Tab } from "../model/tabs";
import { EditorAreaView } from "./EditorArea";
vi.mock("../controller", async (original) => ({
...(await original<object>()),
editorController: () => ({ publishContext: vi.fn(), tabMenu: () => [] }),
}));
vi.mock("./TextEditorHost", () => ({ TextEditorHost: () => <input aria-label="Source" /> }));
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(<EditorAreaView />);
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(<EditorAreaView />);
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(<EditorAreaView />);
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);
});
});
+5 -3
View File
@@ -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()]),
@@ -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 {
@@ -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<HTMLDivElement>(null);
return (
<div className={clsx(styles.split, styles[`mode-${mode}`])}>
<div ref={ref} className={clsx(styles.split, styles[`mode-${mode}`])}>
<div className={styles.editor}>{children}</div>
{mode !== "editor" && <MarkdownPreview path={path} sync={mode === "split"} />}
<div className={styles.modeBar} role="toolbar" aria-label={tx("Markdown editor layout")}>
<IconButton
icon="edit"
size="small"
action="markdown.editorOnly"
actionArg={path}
pressed={mode === "editor"}
/>
<IconButton
icon="split"
size="small"
action="markdown.editorAndPreview"
actionArg={path}
pressed={mode === "split"}
/>
<IconButton
icon="eye"
size="small"
action="markdown.previewOnly"
actionArg={path}
pressed={mode === "preview"}
/>
</div>
{!actionsInTabs && (
<FloatingToolbar targetRef={ref}>
<MarkdownLayoutActions path={path} tabId={tabId} />
</FloatingToolbar>
)}
</div>
);
}
@@ -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 (
<div className={styles.layoutActions} role="toolbar" aria-label={tx("Markdown editor layout")}>
<IconButton
icon="editor-only"
iconSize={16}
size="small"
title="Editor Only"
action="markdown.editorOnly"
actionArg={path}
pressed={mode === "editor"}
/>
<IconButton
icon="editor-preview"
iconSize={16}
size="small"
title="Editor and Preview"
action="markdown.editorAndPreview"
actionArg={path}
pressed={mode === "split"}
/>
<IconButton
icon="preview-only"
iconSize={16}
size="small"
title="Preview Only"
action="markdown.previewOnly"
actionArg={path}
pressed={mode === "preview"}
/>
</div>
);
}
+4 -20
View File
@@ -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 {
+24 -19
View File
@@ -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<HTMLDivElement>(null);
return (
<div className={clsx(styles.split, preview && styles.withPreview)}>
<div className={styles.editor}>
<div ref={ref} className={styles.editor}>
{children}
<div className={styles.bar} role="toolbar" aria-label={tx("Open in browser")}>
<IconButton
icon="eye"
size="small"
action="web.togglePreview"
actionArg={path}
pressed={preview}
title="Built-in Preview"
/>
{browsers.map((b) => (
<FloatingToolbar targetRef={ref} className={styles.browserBar} hideOnTyping={false} retentionMs={1500}>
<div className={styles.actions} role="toolbar" aria-label={tx("Open in browser")}>
<IconButton
key={b.id}
icon={browserIcon(b.id)}
icon="eye"
size="small"
title={b.name}
onClick={() => void openInBrowser(path, b.id)}
iconSize={16}
action="web.togglePreview"
actionArg={path}
pressed={preview}
title="Built-in Preview"
/>
))}
</div>
{browsers.map((b) => (
<IconButton
key={b.id}
icon={browserIcon(b.id)}
size="small"
iconSize={16}
title={b.name}
onClick={() => void openInBrowser(path, b.id)}
/>
))}
</div>
</FloatingToolbar>
</div>
{preview && <WebPreview path={path} />}
</div>
+13
View File
@@ -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);
+27
View File
@@ -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;
}
}
+89
View File
@@ -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<HTMLDivElement>(null);
return (
<div ref={ref} data-testid="editor">
<input aria-label="Source" />
<FloatingToolbar targetRef={ref} hideOnTyping={hideOnTyping}>
<button type="button">Preview</button>
</FloatingToolbar>
</div>
);
}
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(<Fixture />);
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(<Fixture />);
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(<Fixture />);
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(<Fixture hideOnTyping={false} />);
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(<Fixture />);
const before = vi.getTimerCount();
fireEvent.pointerMove(screen.getByTestId("editor"));
expect(vi.getTimerCount()).toBe(before + 1);
unmount();
expect(vi.getTimerCount()).toBe(before);
});
});
+81
View File
@@ -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<HTMLElement | null>;
children: ReactNode;
className?: string;
hideOnTyping?: boolean;
retentionMs?: number;
}) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const target = targetRef.current;
const toolbar = ref.current;
if (!target || !toolbar) return;
let timer: ReturnType<typeof setTimeout> | 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 (
<div ref={ref} className={clsx(styles.toolbar, visible && styles.visible, className)}>
{children}
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="10" height="1" rx="0.5" fill="#6C707E"/>
<rect x="3" y="6" width="10" height="1" rx="0.5" fill="#6C707E"/>
<rect x="3" y="9" width="10" height="1" rx="0.5" fill="#6C707E"/>
<rect x="3" y="12" width="10" height="1" rx="0.5" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 495 B

+7
View File
@@ -0,0 +1,7 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="10" height="1" rx="0.5" fill="#CED0D6"/>
<rect x="3" y="6" width="10" height="1" rx="0.5" fill="#CED0D6"/>
<rect x="3" y="9" width="10" height="1" rx="0.5" fill="#CED0D6"/>
<rect x="3" y="12" width="10" height="1" rx="0.5" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 495 B

+8
View File
@@ -0,0 +1,8 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="3" width="5" height="1" rx="0.5" fill="#6C707E"/>
<rect x="1" y="6" width="5" height="1" rx="0.5" fill="#6C707E"/>
<rect x="1" y="9" width="5" height="1" rx="0.5" fill="#6C707E"/>
<rect x="1" y="12" width="5" height="1" rx="0.5" fill="#6C707E"/>
<path d="M8.5 4C8.5 3.17157 9.17157 2.5 10 2.5H13C13.8284 2.5 14.5 3.17157 14.5 4V12C14.5 12.8284 13.8284 13.5 13 13.5H10C9.17157 13.5 8.5 12.8284 8.5 12V4Z" stroke="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 668 B

@@ -0,0 +1,8 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="3" width="5" height="1" rx="0.5" fill="#CED0D6"/>
<rect x="1" y="6" width="5" height="1" rx="0.5" fill="#CED0D6"/>
<rect x="1" y="9" width="5" height="1" rx="0.5" fill="#CED0D6"/>
<rect x="1" y="12" width="5" height="1" rx="0.5" fill="#CED0D6"/>
<path d="M8.5 4C8.5 3.17157 9.17157 2.5 10 2.5H13C13.8284 2.5 14.5 3.17157 14.5 4V12C14.5 12.8284 13.8284 13.5 13 13.5H10C9.17157 13.5 8.5 12.8284 8.5 12V4Z" stroke="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 668 B

+6
View File
@@ -0,0 +1,6 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="2.5" width="11" height="11" rx="1.5" stroke="#6C707E"/>
<path d="M2.35742 9.5L4.18285 7.67457C4.56044 7.29699 5.16806 7.28268 5.563 7.64207L12.0003 13.5" stroke="#6C707E"/>
<circle cx="10" cy="6" r="1.5" stroke="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 470 B

@@ -0,0 +1,6 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="2.5" width="11" height="11" rx="1.5" stroke="#CED0D6"/>
<path d="M2.35742 9.5L4.18285 7.67457C4.56044 7.29699 5.16806 7.28268 5.563 7.64207L12.0003 13.5" stroke="#CED0D6"/>
<circle cx="10" cy="6" r="1.5" stroke="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 470 B

+3
View File
@@ -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",
+1
View File
@@ -1,5 +1,6 @@
export * from "./icons";
export * from "./Button";
export * from "./FloatingToolbar";
export * from "./Kbd";
export * from "./Tooltip";
export * from "./Input";
+3 -1
View File
@@ -432,7 +432,9 @@ export async function resolveFileEditor(path: string): Promise<FileEditorResolut
export interface TextEditorWrapperContribution {
id: string;
matches(path: string): boolean;
component: ComponentType<{ path: string; tabId: string; children: ReactNode }>;
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<string, TextEditorWrapperContribution> }>(() => ({
@@ -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",