feat(editor): the left bar folds away, and the device sheet takes the whole editor on a phone
A button at the top of the palette and the documentation's bar folds it to a slim column, as the dashboard's own bar does; one state for both, kept in the browser. Up to 639 px the device sheet covers the whole editor, over the bar and the mode buttons, instead of sharing half the height below the surface. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,7 @@ import { networkImage } from './design/exportImage.js';
|
||||
import { exportNames } from './canvas/networkImage.js';
|
||||
import { saveFile } from './download.js';
|
||||
import { Palette, type PaletteKind } from './design/Palette.js';
|
||||
import { useBarFolded } from './design/SideBar.js';
|
||||
import { RouterDialog } from './design/RouterDialog.js';
|
||||
import { DeviceSheet } from './sheet/DeviceSheet.js';
|
||||
import { DesktopSheet, type DesktopWindowName } from './desktop/DesktopSheet.js';
|
||||
@@ -173,6 +174,7 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
|
||||
const editorMode: EditorMode = simulating ? 'simulation' : documenting ? 'documentation' : 'design';
|
||||
const [sheet, setSheet] = useState<OpenSheet | null>(null);
|
||||
const [seeThrough, setSeeThrough] = useSeeThrough();
|
||||
const [barFolded, foldBar] = useBarFolded();
|
||||
// The sheet begins below the toolbar, which wraps on a narrow screen.
|
||||
const toolbar = useRef<HTMLDivElement>(null);
|
||||
const [sheetTop, setSheetTop] = useState(68);
|
||||
@@ -443,9 +445,9 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
|
||||
}, [manager, selection, menu, routerAt, remove, simulating, readOnly]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100%' }}>
|
||||
<div className="editor-root">
|
||||
{simulating ? null : documenting ? (
|
||||
<DocumentationSidebar onItem={onDocumentationItem} onExport={exportImage} onReport={makeReport} readOnly={readOnly} />
|
||||
<DocumentationSidebar onItem={onDocumentationItem} onExport={exportImage} onReport={makeReport} readOnly={readOnly} folded={barFolded} onFold={foldBar} />
|
||||
) : readOnly ? null : (
|
||||
<Palette
|
||||
cableTool={cableTool}
|
||||
@@ -454,6 +456,8 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
|
||||
setCableFrom(null);
|
||||
}}
|
||||
onDevice={onDevice}
|
||||
folded={barFolded}
|
||||
onFold={foldBar}
|
||||
/>
|
||||
)}
|
||||
<div className="editor-work">
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
// tapped, it lands in the middle of the view (Java's tap makes nothing).
|
||||
// „Exportieren“ saves the surface as an image (F4.8c), „Report“ the report as
|
||||
// a PDF (F4.8d); if that fails, Java's „Fehler beim Speichern aufgetreten!“
|
||||
// shows below them.
|
||||
// shows below them. The bar folds away as the palette does (SideBar).
|
||||
|
||||
import { useRef, useState, type CSSProperties } from 'react';
|
||||
import type { DocumentationItemKind } from '@mdg-filius/core';
|
||||
import { DRAG_THRESHOLD } from '../canvas/Canvas.js';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import { SideBar } from './SideBar.js';
|
||||
import addRectangle from '../assets/documentation/add_small.png';
|
||||
import addText from '../assets/documentation/add_text_small.png';
|
||||
import exportIcon from '../assets/documentation/download_small.png';
|
||||
@@ -24,6 +25,8 @@ export interface DocumentationSidebarProps {
|
||||
readonly onReport: () => Promise<void>;
|
||||
/** Who only reads makes no texts or rectangles; the picture and the report stay. */
|
||||
readonly readOnly?: boolean;
|
||||
readonly folded: boolean;
|
||||
readonly onFold: (folded: boolean) => void;
|
||||
}
|
||||
|
||||
const ITEMS: readonly { readonly kind: DocumentationItemKind; readonly icon: string; readonly label: string }[] = [
|
||||
@@ -50,7 +53,7 @@ const item: CSSProperties = {
|
||||
touchAction: 'none',
|
||||
};
|
||||
|
||||
export function DocumentationSidebar({ onItem, onExport, onReport, readOnly = false }: DocumentationSidebarProps) {
|
||||
export function DocumentationSidebar({ onItem, onExport, onReport, readOnly = false, folded, onFold }: DocumentationSidebarProps) {
|
||||
const t = useTranslate();
|
||||
const [ghost, setGhost] = useState<{ kind: DocumentationItemKind; x: number; y: number } | null>(null);
|
||||
const press = useRef<{ kind: DocumentationItemKind; x: number; y: number; dragging: boolean } | null>(null);
|
||||
@@ -94,20 +97,7 @@ export function DocumentationSidebar({ onItem, onExport, onReport, readOnly = fa
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label={t('docu.sidebar')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
padding: 6,
|
||||
overflowY: 'auto',
|
||||
background: 'var(--surface-card)',
|
||||
borderRight: '1px solid var(--border-default)',
|
||||
width: 124,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<SideBar label={t('docu.sidebar')} folded={folded} onFold={onFold}>
|
||||
{(readOnly ? [] : ITEMS).map((each) => (
|
||||
<button
|
||||
key={each.kind}
|
||||
@@ -162,6 +152,6 @@ export function DocumentationSidebar({ onItem, onExport, onReport, readOnly = fa
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
</SideBar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// The palette (Java's design sidebar, GUIDesignSidebar): the cable tool
|
||||
// first, then the devices in Java's order, each with Java's icon and name.
|
||||
// A device is dragged onto the surface, or tapped to land in the middle of
|
||||
// the view; the cable is a tool that stays on until tapped again.
|
||||
// the view; the cable is a tool that stays on until tapped again. The bar
|
||||
// folds away (SideBar).
|
||||
|
||||
import { useRef, useState, type CSSProperties } from 'react';
|
||||
import { PALETTE } from '@mdg-filius/doc';
|
||||
@@ -11,6 +12,7 @@ import { ICON_URLS } from '../canvas/icons.js';
|
||||
import { iconOf } from '../canvas/layout.js';
|
||||
import cableIcon from '../assets/hardware/kabel.png';
|
||||
import { DRAG_THRESHOLD } from '../canvas/Canvas.js';
|
||||
import { SideBar } from './SideBar.js';
|
||||
|
||||
export type PaletteKind = (typeof PALETTE)[number];
|
||||
|
||||
@@ -19,6 +21,8 @@ export interface PaletteProps {
|
||||
readonly onCableTool: (on: boolean) => void;
|
||||
/** A device dropped at a point of the screen, or tapped (no point). */
|
||||
readonly onDevice: (kind: PaletteKind, at: { readonly clientX: number; readonly clientY: number } | null) => void;
|
||||
readonly folded: boolean;
|
||||
readonly onFold: (folded: boolean) => void;
|
||||
}
|
||||
|
||||
const item: CSSProperties = {
|
||||
@@ -40,7 +44,7 @@ const item: CSSProperties = {
|
||||
touchAction: 'none',
|
||||
};
|
||||
|
||||
export function Palette({ cableTool, onCableTool, onDevice }: PaletteProps) {
|
||||
export function Palette({ cableTool, onCableTool, onDevice, folded, onFold }: PaletteProps) {
|
||||
const t = useTranslate();
|
||||
const [ghost, setGhost] = useState<{ kind: PaletteKind; x: number; y: number } | null>(null);
|
||||
const press = useRef<{ kind: PaletteKind; x: number; y: number; dragging: boolean } | null>(null);
|
||||
@@ -74,7 +78,7 @@ export function Palette({ cableTool, onCableTool, onDevice }: PaletteProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<nav aria-label={t('design.palette')} style={{ display: 'flex', flexDirection: 'column', gap: 4, padding: 6, overflowY: 'auto', background: 'var(--surface-card)', borderRight: '1px solid var(--border-default)', width: 124, flexShrink: 0 }}>
|
||||
<SideBar label={t('design.palette')} folded={folded} onFold={onFold}>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...item, cursor: 'pointer', borderColor: cableTool ? 'var(--accent)' : 'transparent', background: cableTool ? 'var(--accent-soft)' : 'transparent' }}
|
||||
@@ -116,6 +120,6 @@ export function Palette({ cableTool, onCableTool, onDevice }: PaletteProps) {
|
||||
style={{ position: 'fixed', left: ghost.x, top: ghost.y, transform: 'translate(-50%, -50%)', opacity: 0.7, pointerEvents: 'none', zIndex: 10 }}
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
</SideBar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// The bar at the left of the surface — the design's palette, the
|
||||
// documentation's sidebar — that folds away to make room for the surface on a
|
||||
// small screen (Java has no such button; it has the room). As the dashboard's
|
||||
// own bar does („Leiste einklappen“, AppShell): folded, it is a slim column
|
||||
// with the one button that brings it back, and that button stands at the top
|
||||
// in the same place either way. One state for both bars, kept in this
|
||||
// browser: whoever folds it on a phone wants it folded next time too.
|
||||
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { useTranslate } from "../i18n/i18n.js";
|
||||
|
||||
const FOLDED_KEY = "filius.barFolded";
|
||||
|
||||
/** Whether the bar is folded, and the change, kept in this browser (where it can be). */
|
||||
export function useBarFolded(): [boolean, (folded: boolean) => void] {
|
||||
const [folded, setFolded] = useState(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(FOLDED_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const change = useCallback((next: boolean) => {
|
||||
setFolded(next);
|
||||
try {
|
||||
window.localStorage.setItem(FOLDED_KEY, String(next));
|
||||
} catch {
|
||||
// Not kept; it still holds for this page.
|
||||
}
|
||||
}, []);
|
||||
return [folded, change];
|
||||
}
|
||||
|
||||
/** A panel with its left column, the arrow saying which way it goes — the dashboard's sign. */
|
||||
function BarIcon({ folded }: { readonly folded: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
width={20}
|
||||
height={20}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x={3} y={4} width={18} height={16} rx={2} />
|
||||
<path d="M9 4v16" />
|
||||
<path d={folded ? "m13 10 2 2-2 2" : "m16 10-2 2 2 2"} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SideBar({
|
||||
label,
|
||||
folded,
|
||||
onFold,
|
||||
children,
|
||||
}: {
|
||||
readonly label: string;
|
||||
readonly folded: boolean;
|
||||
readonly onFold: (folded: boolean) => void;
|
||||
readonly children: ReactNode;
|
||||
}) {
|
||||
const t = useTranslate();
|
||||
const word = t(folded ? "design.unfoldBar" : "design.foldBar");
|
||||
return (
|
||||
<nav
|
||||
aria-label={label}
|
||||
className="side-bar"
|
||||
data-folded={folded ? "true" : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="side-bar__fold"
|
||||
aria-expanded={!folded}
|
||||
aria-label={word}
|
||||
title={word}
|
||||
onClick={() => onFold(!folded)}
|
||||
>
|
||||
<BarIcon folded={folded} />
|
||||
</button>
|
||||
{folded ? null : <div className="side-bar__items">{children}</div>}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,8 @@
|
||||
"connections.remove": "Schnittstelle entfernen",
|
||||
"design.actions": "Aktionen",
|
||||
"design.palette": "Komponenten",
|
||||
"design.foldBar": "Leiste einklappen",
|
||||
"design.unfoldBar": "Leiste ausklappen",
|
||||
"design.redo": "Wiederholen",
|
||||
"design.undo": "Rückgängig",
|
||||
"desktop.home": "Startbildschirm",
|
||||
|
||||
@@ -51,6 +51,8 @@
|
||||
"connections.remove": "Remove interface",
|
||||
"design.actions": "Actions",
|
||||
"design.palette": "Components",
|
||||
"design.foldBar": "Collapse the bar",
|
||||
"design.unfoldBar": "Expand the bar",
|
||||
"design.redo": "Redo",
|
||||
"design.undo": "Undo",
|
||||
"desktop.home": "Home Screen",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// 340 px sheet holds 139. So a segment's words may wrap at their spaces
|
||||
// ("Statische / Adresszuweisung"), the chosen one is not set bold (157 px; the
|
||||
// width would jump on every choice), and where even the longest word of a
|
||||
// segment no longer fits its share — a phone, another system font, English —
|
||||
// segment no longer fits its share — a small phone, another system font —
|
||||
// the segments stand under one another, still one control. That is decided by
|
||||
// measuring the words in the segments' own font, not by a breakpoint: a
|
||||
// breakpoint would hold for one font only.
|
||||
|
||||
@@ -1,6 +1,57 @@
|
||||
/* The device sheet beside the surface on a wide screen, below it on a phone
|
||||
* (the dashboard's phone width, 639 px): the surface shrinks instead of
|
||||
* being covered, so the device stays in sight. */
|
||||
/* The editor: the bar at the left, the surface, the device sheet beside it.
|
||||
* The sheet on a phone (the dashboard's phone width, 639 px) covers all of
|
||||
* it, the bar and the mode buttons too — the root holds it. */
|
||||
.editor-root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* The bar at the left (SideBar): the fold button at the top, the items below
|
||||
* it scrolling on their own; folded, only the button's slim column. */
|
||||
.side-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
width: 124px;
|
||||
padding: 6px;
|
||||
border-right: 1px solid var(--border-default);
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
.side-bar[data-folded="true"] {
|
||||
width: 44px;
|
||||
}
|
||||
|
||||
.side-bar__fold {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.side-bar__fold:hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.side-bar__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* The device sheet beside the surface: the surface shrinks instead of being
|
||||
* covered, so the device stays in sight. */
|
||||
.editor-work {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -57,18 +108,17 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* On a phone a third of the width beside the surface or half its height
|
||||
* below it leaves neither enough to work in: the sheet takes the whole
|
||||
* editor, over the bar and the mode buttons (50); a message (60) stays over
|
||||
* it, and closing it gives the surface back. */
|
||||
@media (max-width: 639px) {
|
||||
.editor-work {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.device-sheet {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 55;
|
||||
width: auto;
|
||||
max-height: 50%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border-default);
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0 -4px 16px color-mix(in srgb, var(--shadow-color) 15%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user