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.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-31 20:24:31 +08:00
1 джерело 17e3cd2b39
коміт 178fc9eaba
24 files changed
+491 -90

No files matched your search

+16 -4
Переглянути файл
@@ -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"
+114
Переглянути файл
@@ -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
Переглянути файл
@@ -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()]),
+5 -15
Переглянути файл
@@ -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 {
+21 -27
Переглянути файл
@@ -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>
);
}
+39
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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>

Після

Ширина:  |  Висота:  |  Розмір: 495 B

+7
Переглянути файл
@@ -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>

Після

Ширина:  |  Висота:  |  Розмір: 495 B

+8
Переглянути файл
@@ -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>

Після

Ширина:  |  Висота:  |  Розмір: 668 B

+8
Переглянути файл
@@ -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>

Після

Ширина:  |  Висота:  |  Розмір: 668 B

+6
Переглянути файл
@@ -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>

Після

Ширина:  |  Висота:  |  Розмір: 470 B

+6
Переглянути файл
@@ -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>

Після

Ширина:  |  Висота:  |  Розмір: 470 B

+3
Переглянути файл
@@ -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
Переглянути файл
@@ -1,5 +1,6 @@
export * from "./icons";
export * from "./Button";
export * from "./FloatingToolbar";
export * from "./Kbd";
export * from "./Tooltip";
export * from "./Input";
+3 -1
Переглянути файл
@@ -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> }>(() => ({