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

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