@scenoco-three/editor 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +23 -17
- package/app/cameraRig.ts +73 -12
- package/app/commands.ts +5 -1
- package/app/desktopControls.ts +13 -10
- package/app/document.ts +474 -427
- package/app/editLog.ts +127 -0
- package/app/editor.css +51 -0
- package/app/gizmo.ts +100 -6
- package/app/index.html +6 -122
- package/app/main.ts +488 -304
- package/app/meta.ts +24 -0
- package/app/picking.ts +47 -0
- package/app/session.ts +476 -311
- package/app/touchControls.ts +85 -62
- package/app/ui-react/AddMemberPopup.tsx +114 -0
- package/app/ui-react/BottomLauncher.tsx +15 -0
- package/app/ui-react/EditorUI.tsx +22 -0
- package/app/ui-react/FilePicker.tsx +80 -0
- package/app/ui-react/HeadBtn.tsx +21 -0
- package/app/ui-react/InspectorSheet.tsx +475 -0
- package/app/ui-react/NodeListSheet.tsx +56 -0
- package/app/ui-react/TopBar.tsx +137 -0
- package/app/ui-react/Tree.tsx +106 -0
- package/app/ui-react/cx.ts +4 -0
- package/app/ui-react/fields.tsx +407 -0
- package/app/ui-react/format.ts +30 -0
- package/app/ui-react/mount.tsx +31 -0
- package/app/ui-react/store.ts +80 -0
- package/app/ui-react/types.ts +88 -0
- package/app/ui-react/ui.module.css +250 -0
- package/app/ui-react/useSize.ts +20 -0
- package/app/values.ts +137 -0
- package/dist/cli/index.js +93 -70
- package/dist/index.d.ts +10 -3
- package/dist/index.js +9 -5
- package/dist/mcp.d.ts +28 -12
- package/dist/mcp.js +172 -131
- package/dist/protocol.d.ts +74 -65
- package/dist/protocol.js +17 -4
- package/dist/server.d.ts +106 -39
- package/dist/server.js +386 -361
- package/package.json +25 -10
- package/app/filePicker.ts +0 -64
- package/app/panels.ts +0 -553
- package/app/toolbar.ts +0 -131
- package/app/wsClient.ts +0 -92
- package/dist/args.d.ts +0 -8
- package/dist/args.js +0 -28
package/app/editLog.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { Edit } from './document.js';
|
|
2
|
+
|
|
3
|
+
/** One reversible step. `redo` is replaced rather than appended while a drag coalesces. */
|
|
4
|
+
interface Step {
|
|
5
|
+
label: string;
|
|
6
|
+
undo: () => void;
|
|
7
|
+
redo: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The undo stack, the pending splices, and who to tell when either changes.
|
|
12
|
+
*
|
|
13
|
+
* Split out of `SceneDocument` because they are two objects: one answers *what the document
|
|
14
|
+
* says* — which element built this node, what may be edited, what an attribute reads as — and
|
|
15
|
+
* this one answers *what has changed since it was opened*. They were one class of forty-eight
|
|
16
|
+
* methods, and the half you were reading was never the half you wanted.
|
|
17
|
+
*
|
|
18
|
+
* Every step is a pair of closures the document supplies, so this knows nothing about XML,
|
|
19
|
+
* elements or the live scene: it decides only when a change is recorded, folded into the
|
|
20
|
+
* previous one, or reversed.
|
|
21
|
+
*/
|
|
22
|
+
export class EditLog {
|
|
23
|
+
private readonly pending: Edit[] = [];
|
|
24
|
+
private readonly undoStack: Step[] = [];
|
|
25
|
+
private redoStack: Step[] = [];
|
|
26
|
+
private readonly listeners = new Set<() => void>();
|
|
27
|
+
private saved = true;
|
|
28
|
+
|
|
29
|
+
/** Everything not yet written to disk. */
|
|
30
|
+
get edits(): readonly Edit[] {
|
|
31
|
+
return this.pending;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get canUndo(): boolean {
|
|
35
|
+
return this.undoStack.length > 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get canRedo(): boolean {
|
|
39
|
+
return this.redoStack.length > 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get isDirty(): boolean {
|
|
43
|
+
return !this.saved;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Record one change.
|
|
48
|
+
*
|
|
49
|
+
* `coalesce` folds it into the previous step when it touches the same thing, so a gizmo drag
|
|
50
|
+
* that produced two hundred edits undoes in one.
|
|
51
|
+
*/
|
|
52
|
+
commit(label: string, apply: () => void, revert: () => void, coalesce: boolean): void {
|
|
53
|
+
const previous = this.undoStack[this.undoStack.length - 1];
|
|
54
|
+
apply();
|
|
55
|
+
if (coalesce && previous?.label === label) {
|
|
56
|
+
previous.redo = apply;
|
|
57
|
+
} else {
|
|
58
|
+
this.undoStack.push({ label, undo: revert, redo: apply });
|
|
59
|
+
}
|
|
60
|
+
this.redoStack = [];
|
|
61
|
+
this.saved = false;
|
|
62
|
+
this.emit();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Record a splice with nothing to apply to the live scene — a structural edit. */
|
|
66
|
+
push(edit: Edit, label: string): void {
|
|
67
|
+
this.commit(
|
|
68
|
+
label,
|
|
69
|
+
() => this.pending.push(edit),
|
|
70
|
+
() => this.pending.pop(),
|
|
71
|
+
false,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Queue an attribute write, replacing any pending write to the same attribute.
|
|
77
|
+
*
|
|
78
|
+
* The **last** write to one attribute wins, so a scrubbed number leaves one edit rather than
|
|
79
|
+
* one per pixel — the file is written once, and the diff shows the value that survived.
|
|
80
|
+
*/
|
|
81
|
+
replaceAttribute(edit: Extract<Edit, { op: 'setAttribute' }>): void {
|
|
82
|
+
const index = this.pending.findIndex(
|
|
83
|
+
(e) =>
|
|
84
|
+
e.op === 'setAttribute' &&
|
|
85
|
+
e.at.line === edit.at.line &&
|
|
86
|
+
e.at.column === edit.at.column &&
|
|
87
|
+
e.name === edit.name,
|
|
88
|
+
);
|
|
89
|
+
if (index !== -1) this.pending.splice(index, 1);
|
|
90
|
+
this.pending.push(edit);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
undo(): void {
|
|
94
|
+
const step = this.undoStack.pop();
|
|
95
|
+
if (!step) return;
|
|
96
|
+
step.undo();
|
|
97
|
+
this.redoStack.push(step);
|
|
98
|
+
this.saved = false;
|
|
99
|
+
this.emit();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
redo(): void {
|
|
103
|
+
const step = this.redoStack.pop();
|
|
104
|
+
if (!step) return;
|
|
105
|
+
step.redo();
|
|
106
|
+
this.undoStack.push(step);
|
|
107
|
+
this.saved = false;
|
|
108
|
+
this.emit();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The file has been written: nothing is pending and nothing is undoable past this point. */
|
|
112
|
+
clear(): void {
|
|
113
|
+
this.pending.length = 0;
|
|
114
|
+
this.undoStack.length = 0;
|
|
115
|
+
this.redoStack = [];
|
|
116
|
+
this.saved = true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
onChange(listener: () => void): () => void {
|
|
120
|
+
this.listeners.add(listener);
|
|
121
|
+
return () => this.listeners.delete(listener);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
emit(): void {
|
|
125
|
+
for (const listener of this.listeners) listener();
|
|
126
|
+
}
|
|
127
|
+
}
|
package/app/editor.css
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The page shell and the theme.
|
|
3
|
+
*
|
|
4
|
+
* `ui.module.css` styles the chrome and reads these three variables for every panel — so they
|
|
5
|
+
* have to be defined here, on `:root`, not inside the module: a CSS Module hashes class names but
|
|
6
|
+
* a custom property still resolves through the cascade, and an undefined `var(--line)` is not an
|
|
7
|
+
* error. It silently paints nothing, which is exactly how every panel border went missing.
|
|
8
|
+
*/
|
|
9
|
+
:root {
|
|
10
|
+
color-scheme: dark;
|
|
11
|
+
/* Translucent, because a panel floats over the viewport and the scene behind it is context. */
|
|
12
|
+
--panel: rgba(20, 24, 32, 0.92);
|
|
13
|
+
--line: #2a3344;
|
|
14
|
+
/* A panel floats over the scene, so its outline has to hold against whatever is behind it —
|
|
15
|
+
brighter than `--line`, which divides rows *inside* a panel and should stay quiet. */
|
|
16
|
+
--edge: #3d4b64;
|
|
17
|
+
--accent: #4af0e0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
* { box-sizing: border-box; }
|
|
21
|
+
|
|
22
|
+
html,
|
|
23
|
+
body {
|
|
24
|
+
margin: 0;
|
|
25
|
+
height: 100%;
|
|
26
|
+
overflow: hidden;
|
|
27
|
+
background: #15181f;
|
|
28
|
+
color: #d6e2f0;
|
|
29
|
+
font: 13px/1.5 ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#viewport {
|
|
33
|
+
position: fixed;
|
|
34
|
+
inset: 0;
|
|
35
|
+
width: 100%;
|
|
36
|
+
height: 100%;
|
|
37
|
+
display: block;
|
|
38
|
+
touch-action: none;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/*
|
|
42
|
+
* The chrome overlays the viewport but lets pointer events fall through to the canvas; each
|
|
43
|
+
* panel re-enables them on its own surface. Otherwise an invisible full-screen div would eat
|
|
44
|
+
* every click meant for the scene.
|
|
45
|
+
*/
|
|
46
|
+
#sc-ui {
|
|
47
|
+
position: fixed;
|
|
48
|
+
inset: 0;
|
|
49
|
+
z-index: 5;
|
|
50
|
+
pointer-events: none;
|
|
51
|
+
}
|
package/app/gizmo.ts
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { Quaternion, Vector3, type Camera, type Object3D, type Scene } from 'three';
|
|
2
2
|
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
|
|
3
3
|
|
|
4
4
|
export type GizmoMode = 'translate' | 'rotate' | 'scale';
|
|
5
5
|
|
|
6
|
+
/** Which axes the handles follow: the world's, or the object's own. */
|
|
7
|
+
export type GizmoSpace = 'world' | 'local';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* How far the pointer must travel before a press on a handle counts as a drag.
|
|
11
|
+
*
|
|
12
|
+
* No click is perfectly still — a real mouse moves a pixel or two between down and up — and
|
|
13
|
+
* `TransformControls` applies every one of those pixels. Without a threshold, clicking an object
|
|
14
|
+
* in scale mode quietly resizes it by a fraction of a percent, which is invisible on screen and
|
|
15
|
+
* permanent in the document: the file gains a `scale` attribute nobody asked for.
|
|
16
|
+
*/
|
|
17
|
+
const DRAG_THRESHOLD = 3;
|
|
18
|
+
|
|
6
19
|
export interface GizmoOptions {
|
|
7
20
|
/** Live refresh while dragging (e.g. update the inspector numbers). */
|
|
8
21
|
onObjectChange?: () => void;
|
|
@@ -24,26 +37,84 @@ export class Gizmo {
|
|
|
24
37
|
private readonly controls: TransformControls;
|
|
25
38
|
private readonly helper: Object3D;
|
|
26
39
|
|
|
40
|
+
private readonly scene: Scene;
|
|
41
|
+
|
|
42
|
+
/** Where the press started, while one is in progress. */
|
|
43
|
+
private pressedAt: { x: number; y: number } | null = null;
|
|
44
|
+
/** True once a press has travelled far enough to be a drag. */
|
|
45
|
+
private travelled = false;
|
|
46
|
+
/** The attached object's transform at the moment of the press, to put back after a click. */
|
|
47
|
+
private readonly grabbed = {
|
|
48
|
+
position: new Vector3(),
|
|
49
|
+
quaternion: new Quaternion(),
|
|
50
|
+
scale: new Vector3(),
|
|
51
|
+
/** Kept alongside the quaternion because it is the form the document writes. */
|
|
52
|
+
eulerAngles: new Vector3(),
|
|
53
|
+
};
|
|
54
|
+
|
|
27
55
|
constructor(
|
|
28
56
|
camera: Camera,
|
|
29
57
|
domElement: HTMLElement,
|
|
30
|
-
|
|
58
|
+
scene: Scene,
|
|
31
59
|
touch: boolean,
|
|
32
60
|
opts: GizmoOptions = {},
|
|
33
61
|
) {
|
|
62
|
+
this.scene = scene;
|
|
34
63
|
this.controls = new TransformControls(camera, domElement);
|
|
35
64
|
this.controls.setSize(touch ? 1.4 : 1); // fat-finger handles on touch
|
|
36
65
|
this.helper = this.controls.getHelper();
|
|
37
66
|
this.helper.userData.scenocoEditorOnly = true; // keep it out of the Hierarchy + picking
|
|
38
67
|
scene.add(this.helper);
|
|
39
68
|
|
|
40
|
-
|
|
69
|
+
domElement.addEventListener('pointerdown', (event) => {
|
|
70
|
+
this.pressedAt = { x: event.clientX, y: event.clientY };
|
|
71
|
+
this.travelled = false;
|
|
72
|
+
});
|
|
73
|
+
domElement.addEventListener('pointermove', (event) => {
|
|
74
|
+
if (!this.pressedAt || this.travelled) return;
|
|
75
|
+
this.travelled = Math.hypot(event.clientX - this.pressedAt.x, event.clientY - this.pressedAt.y) > DRAG_THRESHOLD;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// A press that never travelled changed nothing the user meant to change, so the object goes
|
|
79
|
+
// back to where it was grabbed and the document never hears about it.
|
|
80
|
+
this.controls.addEventListener('objectChange', () => {
|
|
81
|
+
if (this.travelled) opts.onObjectChange?.();
|
|
82
|
+
});
|
|
41
83
|
this.controls.addEventListener('dragging-changed', (e) => {
|
|
42
84
|
// Fired true at drag start, false at drag end.
|
|
43
|
-
|
|
85
|
+
const object = this.controls.object;
|
|
86
|
+
if (!object) return;
|
|
87
|
+
if (e.value) {
|
|
88
|
+
this.grabbed.position.copy(object.position);
|
|
89
|
+
this.grabbed.quaternion.copy(object.quaternion);
|
|
90
|
+
this.grabbed.scale.copy(object.scale);
|
|
91
|
+
this.grabbed.eulerAngles.copy(object.eulerAngles);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.pressedAt = null;
|
|
95
|
+
if (this.travelled) opts.onCommit?.(object);
|
|
96
|
+
else {
|
|
97
|
+
object.position.copy(this.grabbed.position);
|
|
98
|
+
object.quaternion.copy(this.grabbed.quaternion);
|
|
99
|
+
object.scale.copy(this.grabbed.scale);
|
|
100
|
+
}
|
|
44
101
|
});
|
|
45
102
|
}
|
|
46
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The attached object's value for one channel as it was when the drag began.
|
|
106
|
+
*
|
|
107
|
+
* Undo needs this and cannot work it out later. By the time the first edit is recorded the
|
|
108
|
+
* object has already moved — the write happens *because* it moved — so a "before" captured
|
|
109
|
+
* there is a frame late. For a channel the document writes, undo restores the text and the
|
|
110
|
+
* error never shows; for one it does not write (a rotation left at zero, a scale left at one,
|
|
111
|
+
* which is most of them) there is nothing to restore from, and the object is left wherever
|
|
112
|
+
* that first frame put it.
|
|
113
|
+
*/
|
|
114
|
+
grabbedValue(channel: 'position' | 'eulerAngles' | 'scale'): Vector3 {
|
|
115
|
+
return this.grabbed[channel];
|
|
116
|
+
}
|
|
117
|
+
|
|
47
118
|
/** True while a handle is being dragged — camera controls check this to yield. */
|
|
48
119
|
get dragging(): boolean {
|
|
49
120
|
return this.controls.dragging;
|
|
@@ -56,12 +127,35 @@ export class Gizmo {
|
|
|
56
127
|
return this.controls.mode as GizmoMode;
|
|
57
128
|
}
|
|
58
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Whether the handles line up with the world's axes or the object's own — Unity's
|
|
132
|
+
* Global/Local button.
|
|
133
|
+
*
|
|
134
|
+
* It changes what a drag means, not just how it looks: dragging the green arrow moves a node
|
|
135
|
+
* along world Y in one space and along its own up in the other, and for anything rotated the
|
|
136
|
+
* two are different edits.
|
|
137
|
+
*/
|
|
138
|
+
setSpace(space: GizmoSpace): void {
|
|
139
|
+
this.controls.setSpace(space);
|
|
140
|
+
}
|
|
141
|
+
get space(): GizmoSpace {
|
|
142
|
+
return this.controls.space as GizmoSpace;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Point the handles at an object — never at the handles themselves.
|
|
147
|
+
*
|
|
148
|
+
* Attaching the control to its own helper makes the helper its own parent's target: three walks
|
|
149
|
+
* `updateMatrixWorld` between the two for ever and the page dies on a stack overflow. It is one
|
|
150
|
+
* mis-routed pick away, and there is no legitimate reason to aim a gizmo at a gizmo, so it is
|
|
151
|
+
* refused here rather than trusted to every caller.
|
|
152
|
+
*/
|
|
59
153
|
attach(object: Object3D | null): void {
|
|
60
|
-
if (object) this.controls.attach(object);
|
|
154
|
+
if (object && !this.contains(object)) this.controls.attach(object);
|
|
61
155
|
else this.controls.detach();
|
|
62
156
|
}
|
|
63
157
|
|
|
64
|
-
/** True if `object` is part of the gizmo's own helper
|
|
158
|
+
/** True if `object` is part of the gizmo's own helper. */
|
|
65
159
|
contains(object: Object3D): boolean {
|
|
66
160
|
for (let o: Object3D | null = object; o; o = o.parent) if (o === this.helper) return true;
|
|
67
161
|
return false;
|
package/app/index.html
CHANGED
|
@@ -2,129 +2,13 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
-
<title>SceNoCo
|
|
7
|
-
<
|
|
8
|
-
html, body { margin: 0; height: 100%; background: #15181f; overflow: hidden; color: #d6e2f0; }
|
|
9
|
-
canvas { display: block; width: 100%; height: 100%; }
|
|
10
|
-
:root { --panel: rgba(20, 24, 32, 0.92); --line: #2a3344; --accent: #4af0e0; }
|
|
11
|
-
/* Each side is a slide-out drawer: a panel + a thin arrow tab that stays on
|
|
12
|
-
screen when collapsed. */
|
|
13
|
-
.sc-drawer { position: fixed; top: 0; bottom: 0; z-index: 5; display: flex;
|
|
14
|
-
transition: transform 0.22s ease; }
|
|
15
|
-
.sc-left { left: 0; }
|
|
16
|
-
.sc-right { right: 0; }
|
|
17
|
-
.sc-left.collapsed { transform: translateX(calc(-100% + 22px)); }
|
|
18
|
-
.sc-right.collapsed { transform: translateX(calc(100% - 22px)); }
|
|
19
|
-
.sc-panel {
|
|
20
|
-
width: 280px; height: 100%; background: var(--panel); overflow: auto;
|
|
21
|
-
font: 12px/1.5 ui-monospace, monospace; backdrop-filter: blur(6px);
|
|
22
|
-
}
|
|
23
|
-
.sc-left .sc-panel { border-right: 1px solid var(--line); }
|
|
24
|
-
.sc-right .sc-panel { border-left: 1px solid var(--line); }
|
|
25
|
-
.sc-tab {
|
|
26
|
-
flex: 0 0 22px; background: var(--panel); color: #9fe7ff; cursor: pointer;
|
|
27
|
-
border: 0; border-left: 1px solid var(--line); border-right: 1px solid var(--line);
|
|
28
|
-
font-size: 15px; display: flex; align-items: center; justify-content: center;
|
|
29
|
-
}
|
|
30
|
-
.sc-tab:hover { color: var(--accent); background: rgba(74, 240, 224, 0.1); }
|
|
31
|
-
.sc-title { padding: 8px 12px; font-weight: 700; color: #9fe7ff; letter-spacing: 0.06em;
|
|
32
|
-
text-transform: uppercase; font-size: 11px; border-bottom: 1px solid var(--line); position: sticky; top: 0;
|
|
33
|
-
background: var(--panel); }
|
|
34
|
-
.sc-node { display: flex; align-items: center; gap: 4px; min-height: 28px; padding: 2px 8px; cursor: pointer;
|
|
35
|
-
white-space: nowrap; overflow: hidden; }
|
|
36
|
-
.sc-node:hover { background: rgba(74, 240, 224, 0.08); }
|
|
37
|
-
.sc-node.sel { background: rgba(74, 240, 224, 0.18); color: #eafdff; }
|
|
38
|
-
.sc-node .ty { color: #6f93b8; }
|
|
39
|
-
.sc-caret { flex: 0 0 14px; width: 14px; text-align: center; color: #6f93b8; cursor: pointer; user-select: none; }
|
|
40
|
-
.sc-inspector { padding: 8px 10px; }
|
|
41
|
-
.sc-sec { display: flex; align-items: center; gap: 4px; margin: 10px 0 4px; color: #9fe7ff; font-weight: 700;
|
|
42
|
-
border-bottom: 1px solid var(--line); padding-bottom: 3px; cursor: pointer; user-select: none;
|
|
43
|
-
text-transform: uppercase; letter-spacing: 0.04em; font-size: 11px; }
|
|
44
|
-
.sc-sec.sc-sec-plain { cursor: default; }
|
|
45
|
-
.sc-sec .sc-caret { flex: 0 0 12px; width: 12px; }
|
|
46
|
-
.sc-sec-body { padding: 1px 0 2px; }
|
|
47
|
-
.sc-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; min-height: 30px; padding: 2px 0; }
|
|
48
|
-
.sc-row .k { color: #8fa6c4; flex: 0 0 auto; }
|
|
49
|
-
.sc-row .v { color: #e7f0fb; text-align: right; word-break: break-word; display: flex; align-items: center;
|
|
50
|
-
justify-content: flex-end; gap: 4px; }
|
|
51
|
-
.sc-empty { color: #6f8099; padding: 12px 0; }
|
|
52
|
-
/* editable controls — 16px font avoids iOS tap-zoom; generous hit areas for touch */
|
|
53
|
-
.sc-input { background: #0e1219; color: #e7f0fb; border: 1px solid var(--line); border-radius: 5px;
|
|
54
|
-
padding: 4px 6px; font: 16px ui-monospace, monospace; min-height: 28px; box-sizing: border-box; }
|
|
55
|
-
.sc-input:focus { outline: none; border-color: var(--accent); }
|
|
56
|
-
.sc-num { width: 64px; text-align: right; }
|
|
57
|
-
.sc-axes { display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
|
|
58
|
-
.sc-axis { display: flex; align-items: center; gap: 2px; }
|
|
59
|
-
.sc-axis-k { color: #6f93b8; font-size: 11px; width: 12px; text-align: center; }
|
|
60
|
-
.sc-scrub { cursor: ew-resize; touch-action: none; }
|
|
61
|
-
.sc-toggle { width: 22px; height: 22px; accent-color: var(--accent); }
|
|
62
|
-
.sc-color { display: flex; align-items: center; gap: 5px; }
|
|
63
|
-
.sc-swatch { width: 30px; height: 28px; padding: 0; border: 1px solid var(--line); border-radius: 5px;
|
|
64
|
-
background: none; cursor: pointer; }
|
|
65
|
-
.sc-hex { width: 78px; }
|
|
66
|
-
.sc-select { min-width: 96px; cursor: pointer; }
|
|
67
|
-
#hint { position: fixed; bottom: 10px; left: 50%; transform: translateX(-50%); padding: 6px 14px;
|
|
68
|
-
border-radius: 8px; background: rgba(0,0,0,.55); color: #9fb5cf; font: 11px ui-monospace, monospace;
|
|
69
|
-
pointer-events: none; }
|
|
70
|
-
/* floating toolbars (touch-reachable, top center): play panel + movement panel */
|
|
71
|
-
.sc-toolbars { position: fixed; top: 10px; left: 50%; transform: translateX(-50%); z-index: 6;
|
|
72
|
-
display: flex; align-items: flex-start; gap: 8px; }
|
|
73
|
-
.sc-toolbar { display: flex; align-items: center; gap: 2px; padding: 5px; border-radius: 12px;
|
|
74
|
-
background: var(--panel); border: 1px solid var(--line); backdrop-filter: blur(6px); }
|
|
75
|
-
.sc-tbtn { position: relative; width: 40px; height: 40px; display: flex; align-items: center;
|
|
76
|
-
justify-content: center; padding: 0; border: 1px solid transparent; border-radius: 9px;
|
|
77
|
-
background: transparent; color: #cfe0f2; cursor: pointer; }
|
|
78
|
-
.sc-tbtn svg { width: 22px; height: 22px; display: block; }
|
|
79
|
-
.sc-tbtn:hover:not(:disabled) { background: rgba(74, 240, 224, 0.12); color: #eafdff; }
|
|
80
|
-
.sc-tbtn.active { background: rgba(74, 240, 224, 0.2); border-color: var(--accent); color: #eafdff; }
|
|
81
|
-
.sc-tbtn:disabled { opacity: 0.3; cursor: default; }
|
|
82
|
-
.sc-tbtn.sc-save.dirty { color: var(--accent); }
|
|
83
|
-
.sc-tbtn.sc-save.dirty::after { content: ''; position: absolute; top: 7px; right: 7px;
|
|
84
|
-
width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
|
|
85
|
-
.sc-tbtn.sc-save.saving { opacity: 0.5; }
|
|
86
|
-
.sc-tsep { width: 1px; height: 24px; background: var(--line); margin: 0 3px; }
|
|
87
|
-
/* conflict banner: unsaved edits vs. an external file change */
|
|
88
|
-
#sc-conflict { position: fixed; top: 62px; left: 50%; transform: translateX(-50%); z-index: 7;
|
|
89
|
-
display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 10px;
|
|
90
|
-
background: rgba(40, 26, 26, 0.96); border: 1px solid #6b3a3a; color: #ffd9d9;
|
|
91
|
-
font: 13px ui-monospace, monospace; backdrop-filter: blur(6px); }
|
|
92
|
-
#sc-conflict button { border: 1px solid #6b3a3a; border-radius: 7px; background: transparent;
|
|
93
|
-
color: #ffd9d9; padding: 5px 10px; cursor: pointer; font: 12px ui-monospace, monospace; }
|
|
94
|
-
#sc-conflict button:first-of-type { background: var(--accent); color: #0e1219; border-color: var(--accent); }
|
|
95
|
-
#sc-conflict button:hover { filter: brightness(1.1); }
|
|
96
|
-
/* component-load error banner: last-good is live, this says what's broken */
|
|
97
|
-
#sc-error { position: fixed; top: 62px; left: 50%; transform: translateX(-50%); z-index: 7;
|
|
98
|
-
display: flex; align-items: flex-start; gap: 10px; max-width: min(680px, 92vw);
|
|
99
|
-
padding: 9px 13px; border-radius: 10px; background: rgba(44, 22, 14, 0.97);
|
|
100
|
-
border: 1px solid #7a4326; color: #ffdcc2; font: 12px/1.45 ui-monospace, monospace;
|
|
101
|
-
backdrop-filter: blur(6px); box-shadow: 0 10px 34px rgba(0, 0, 0, 0.5); }
|
|
102
|
-
#sc-error > span { white-space: pre-wrap; overflow: auto; max-height: 34vh; }
|
|
103
|
-
#sc-error button { flex: 0 0 auto; border: 1px solid #7a4326; border-radius: 7px; background: transparent;
|
|
104
|
-
color: #ffdcc2; padding: 5px 10px; cursor: pointer; font: 12px ui-monospace, monospace; }
|
|
105
|
-
#sc-error button:hover { filter: brightness(1.15); }
|
|
106
|
-
/* start-up file picker: shown when the editor opens with no ?file= */
|
|
107
|
-
.sc-picker { position: fixed; inset: 0; z-index: 8; display: flex; align-items: center;
|
|
108
|
-
justify-content: center; background: rgba(10, 12, 18, 0.72); backdrop-filter: blur(4px); }
|
|
109
|
-
.sc-picker-panel { width: min(440px, 88vw); max-height: 76vh; display: flex; flex-direction: column;
|
|
110
|
-
background: var(--panel); border: 1px solid var(--line); border-radius: 14px; overflow: hidden;
|
|
111
|
-
font: 13px/1.5 ui-monospace, monospace; box-shadow: 0 18px 60px rgba(0, 0, 0, 0.5); }
|
|
112
|
-
.sc-picker-title { padding: 14px 18px; font-weight: 700; color: #9fe7ff; letter-spacing: 0.06em;
|
|
113
|
-
text-transform: uppercase; font-size: 12px; border-bottom: 1px solid var(--line); }
|
|
114
|
-
.sc-picker-list { overflow: auto; padding: 6px; }
|
|
115
|
-
.sc-picker-row { width: 100%; display: flex; align-items: center; justify-content: space-between;
|
|
116
|
-
gap: 10px; padding: 9px 12px; border: 0; border-radius: 8px; background: transparent; color: #e7f0fb;
|
|
117
|
-
font: 13px ui-monospace, monospace; text-align: left; cursor: pointer; }
|
|
118
|
-
.sc-picker-row:hover { background: rgba(74, 240, 224, 0.12); color: #eafdff; }
|
|
119
|
-
.sc-picker-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
120
|
-
.sc-picker-kind { flex: 0 0 auto; color: #6f93b8; font-size: 11px; text-transform: uppercase;
|
|
121
|
-
letter-spacing: 0.04em; border: 1px solid var(--line); border-radius: 5px; padding: 1px 6px; }
|
|
122
|
-
.sc-picker-empty { padding: 22px 18px; color: #8fa6c4; text-align: center; }
|
|
123
|
-
</style>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
|
+
<title>SceNoCo editor</title>
|
|
7
|
+
<link rel="stylesheet" href="./editor.css" />
|
|
124
8
|
</head>
|
|
125
9
|
<body>
|
|
126
|
-
<canvas id="
|
|
127
|
-
<div id="
|
|
128
|
-
<script type="module" src="
|
|
10
|
+
<canvas id="viewport"></canvas>
|
|
11
|
+
<div id="sc-ui"></div>
|
|
12
|
+
<script type="module" src="./main.ts"></script>
|
|
129
13
|
</body>
|
|
130
14
|
</html>
|