mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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` |
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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,5 +1,6 @@
|
||||
export * from "./icons";
|
||||
export * from "./Button";
|
||||
export * from "./FloatingToolbar";
|
||||
export * from "./Kbd";
|
||||
export * from "./Tooltip";
|
||||
export * from "./Input";
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user