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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user