feat: editor improvements and collapsible sidebars

Add collapse/expand toggle for the AppShell navigation sidebar and the
editor properties panel (both persisted to localStorage). Bundles other
in-progress editor work including position anchors, outlet sizing, PBR
textures, window slope/frame depth, curtain metadata, and various 2D/3D
rendering tweaks.
This commit is contained in:
2026-04-08 12:27:57 +03:00
parent aa8a874348
commit d8a914bf2a
116 changed files with 7324 additions and 1114 deletions
@@ -0,0 +1,108 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { Modal } from './Modal';
import styles from './text-prompt-modal.module.css';
interface TextPromptModalProps {
readonly open: boolean;
readonly title: string;
readonly initialValue?: string;
readonly placeholder?: string;
readonly confirmLabel?: string;
readonly cancelLabel?: string;
readonly multiline?: boolean;
readonly onConfirm: (value: string) => void;
readonly onCancel: () => void;
}
/**
* Lightweight replacement for `window.prompt` — a controlled text input inside
* the shared Modal. Submits on Enter (or Cmd/Ctrl+Enter for multiline) and
* cancels on Escape (handled by Modal).
*/
export function TextPromptModal({
open,
title,
initialValue = '',
placeholder,
confirmLabel = 'OK',
cancelLabel = 'Cancel',
multiline = false,
onConfirm,
onCancel,
}: TextPromptModalProps) {
const [value, setValue] = useState(initialValue);
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
useEffect(() => {
if (open) {
setValue(initialValue);
// Wait for the modal to mount, then focus + select the input so the
// user can type or replace immediately.
requestAnimationFrame(() => {
const el = inputRef.current;
if (el) {
el.focus();
el.select();
}
});
}
}, [open, initialValue]);
const handleConfirm = useCallback(() => {
onConfirm(value);
}, [onConfirm, value]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (event.key === 'Enter' && (!multiline || event.metaKey || event.ctrlKey)) {
event.preventDefault();
handleConfirm();
}
},
[handleConfirm, multiline],
);
return (
<Modal
open={open}
onClose={onCancel}
title={title}
footer={
<div className={styles.actions}>
<button type="button" className={styles.button} onClick={onCancel}>
{cancelLabel}
</button>
<button
type="button"
className={`${styles.button} ${styles.buttonPrimary}`}
onClick={handleConfirm}
>
{confirmLabel}
</button>
</div>
}
>
{multiline ? (
<textarea
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
className={styles.textarea}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={4}
/>
) : (
<input
ref={inputRef as React.RefObject<HTMLInputElement>}
type="text"
className={styles.input}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
/>
)}
</Modal>
);
}
@@ -0,0 +1,77 @@
.input,
.textarea {
width: 100%;
box-sizing: border-box;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg);
color: var(--color-text-primary);
font-size: var(--font-size-base);
line-height: 1.5;
transition: border-color var(--transition-fast),
box-shadow var(--transition-fast);
}
.input::placeholder,
.textarea::placeholder {
color: var(--color-text-tertiary, var(--color-text-secondary));
}
.input:focus,
.textarea:focus {
outline: none;
border-color: var(--color-accent-500, var(--color-focus-ring));
box-shadow: 0 0 0 3px var(--color-focus-ring-soft, rgba(99, 102, 241, 0.18));
}
.textarea {
resize: vertical;
min-height: 96px;
font-family: inherit;
}
.actions {
display: flex;
gap: var(--space-3);
justify-content: flex-end;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 80px;
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg);
color: var(--color-text-primary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
}
.button:hover {
background-color: var(--color-bg-hover);
border-color: var(--color-border-strong, var(--color-border));
}
.button:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
.buttonPrimary {
background-color: var(--color-accent-600);
border-color: var(--color-accent-600);
color: var(--color-bg);
}
.buttonPrimary:hover {
background-color: var(--color-accent-700);
border-color: var(--color-accent-700);
}