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

+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";