@unoverse-platform/studio 0.1.21 → 0.1.23
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/package.json +1 -1
- package/screens/ComponentsView.tsx +4 -21
- package/screens/TemplatesView.tsx +59 -6
- package/screens/states.ts +59 -16
- package/vendor/sdk/lib/primitives.tsx +101 -2
- package/vendor/sdk/lib/render.tsx +1 -1
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@ import { ComponentStore } from "@unoverse/sdk";
|
|
|
8
8
|
import { UnoverseComponent, type ResolvedTheme } from "@unoverse/sdk";
|
|
9
9
|
import { client, authedFetch, hasSourceAccess } from "studio-host";
|
|
10
10
|
import { SourceView } from "./SourceView";
|
|
11
|
-
import { stateActivation } from "./states";
|
|
11
|
+
import { stateActivation, faceOf, switchLayers } from "./states";
|
|
12
12
|
import { usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
|
|
13
13
|
|
|
14
14
|
const CHAT_ID = "demo";
|
|
@@ -198,28 +198,11 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
|
|
|
198
198
|
// keys it declares in `state` — that is what the toolbar is allowed to write.
|
|
199
199
|
const subStatesOf = (faceName: string): { on: string; name: string }[] => {
|
|
200
200
|
if (!current?.root || !hasFaces || faceName === "inline") return [];
|
|
201
|
-
let face: unknown;
|
|
202
|
-
const findFace = (n: any): void => {
|
|
203
|
-
if (face || !n || typeof n !== "object") return;
|
|
204
|
-
if (Array.isArray(n)) { n.forEach(findFace); return; }
|
|
205
|
-
if (n.type === "Switch" && n.on === "defaultState" && n.cases?.[faceName]) { face = n.cases[faceName]; return; }
|
|
206
|
-
Object.values(n).forEach(findFace);
|
|
207
|
-
};
|
|
208
|
-
findFace(current.root);
|
|
209
201
|
// The component's OWN state keys — everything else in the subtree belongs to an
|
|
210
|
-
// inlined atom or shared component, or is plain data.
|
|
202
|
+
// inlined atom or shared component, or is plain data. The derivation itself is
|
|
203
|
+
// the SHARED layer resolver (states.ts, UNOVERSE_LAYERS.md §3c).
|
|
211
204
|
const ownKeys = new Set(Object.keys((current.state ?? {}) as Record<string, unknown>));
|
|
212
|
-
|
|
213
|
-
const walk = (n: any): void => {
|
|
214
|
-
if (!n || typeof n !== "object") return;
|
|
215
|
-
if (Array.isArray(n)) { n.forEach(walk); return; }
|
|
216
|
-
if (n.type === "Switch" && typeof n.on === "string" && n.on !== "defaultState" && ownKeys.has(n.on) && n.cases && typeof n.cases === "object") {
|
|
217
|
-
for (const k of Object.keys(n.cases)) if (k !== "default" && !out.some((s) => s.name === k)) out.push({ on: n.on, name: k });
|
|
218
|
-
}
|
|
219
|
-
Object.values(n).forEach(walk);
|
|
220
|
-
};
|
|
221
|
-
walk(face);
|
|
222
|
-
return out;
|
|
205
|
+
return switchLayers(faceOf(current.root, faceName), ownKeys);
|
|
223
206
|
};
|
|
224
207
|
const subStates = subStatesOf(masterState);
|
|
225
208
|
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* ⛔ THIS FILE IS NOT TO BE EDITED. ⛔
|
|
4
|
+
*
|
|
5
|
+
* Every single change in this file — and any change made on behalf of a
|
|
6
|
+
* component or template that seems to need one here — MUST be confirmed by
|
|
7
|
+
* Gavin first. No exceptions: not bug fixes, not one-liners, not reverts.
|
|
8
|
+
* State the intended diff and wait for an explicit yes.
|
|
9
|
+
*
|
|
10
|
+
* Components and templates are DATA (rx/). If a template seems to need code
|
|
11
|
+
* here, the answer is almost always to express it as data — or to ask.
|
|
12
|
+
* ============================================================================
|
|
13
|
+
*
|
|
2
14
|
* Templates view — renders a TEMPLATE definition (layout) and fills its
|
|
3
15
|
* ComponentSlots from a mock conversation seeded into the store.
|
|
4
16
|
*
|
|
@@ -11,8 +23,9 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from
|
|
|
11
23
|
import { useAuth } from "react-oidc-context";
|
|
12
24
|
import { usePersistentState, useRegistryToggles, ToggleSwitch } from "studio-host";
|
|
13
25
|
import { ComponentStore } from "@unoverse/sdk";
|
|
14
|
-
import { StreamedUnoverseTemplate, useUnoverseConnection, computeAppWidth, resolveActiveLayout, type ResolvedTheme, type UseVoiceServiceConfig } from "@unoverse/sdk";
|
|
26
|
+
import { StreamedUnoverseTemplate, useUnoverseConnection, computeAppWidth, resolveActiveLayout, visible, type ResolvedTheme, type UseVoiceServiceConfig } from "@unoverse/sdk";
|
|
15
27
|
import { NativeAppHost } from "./NativeAppHost";
|
|
28
|
+
import { inArrangement } from "./states";
|
|
16
29
|
import { client, authedFetch, hasAuth, UNOVERSE_BASE } from "studio-host";
|
|
17
30
|
|
|
18
31
|
// Per-APP test conversation: each app chats in its own conversation id, so testing
|
|
@@ -46,6 +59,10 @@ function loadConn(): typeof CONN_DEFAULTS {
|
|
|
46
59
|
|
|
47
60
|
interface PropDef {
|
|
48
61
|
default?: unknown;
|
|
62
|
+
// Briefed pages declare their Studio mock as `preview` (ComponentsView reads the
|
|
63
|
+
// same pair) — the mock is `preview ?? default`, per the authoring contract.
|
|
64
|
+
preview?: unknown;
|
|
65
|
+
type?: string;
|
|
49
66
|
}
|
|
50
67
|
interface Def {
|
|
51
68
|
name: string;
|
|
@@ -96,7 +113,9 @@ interface Def {
|
|
|
96
113
|
// Per-STATE mock seeding: `{ "<state>": ["coursecard", "coursecard"] }` — the
|
|
97
114
|
// author decides what each state's preview shows (a repeated name = several
|
|
98
115
|
// instances). Supersedes the legacy flat `previewComponents`.
|
|
99
|
-
|
|
116
|
+
// Per-state mock: an ARRAY seeds components; an OBJECT is authored template-state
|
|
117
|
+
// data (what the workflow would have echoed) merged verbatim on pick.
|
|
118
|
+
preview?: Record<string, string[] | Record<string, unknown>>;
|
|
100
119
|
previewComponents?: string[];
|
|
101
120
|
fluidHeight?: boolean;
|
|
102
121
|
// The app's named default state on load (the single authored field, OPEN name):
|
|
@@ -147,6 +166,13 @@ export function TemplatesView({
|
|
|
147
166
|
// null = the default layout. Independent of the local-state axis — any layout can
|
|
148
167
|
// combine with any local state (a voice List layout while agent-speaking).
|
|
149
168
|
const [activeLayout, setActiveLayout] = useState<string | null>(null);
|
|
169
|
+
// A STATE BELONGS TO THE LAYOUTS THAT DRAW IT (UNOVERSE_LAYERS.md §3c) — same shared
|
|
170
|
+
// layer resolver ComponentsView uses for a face's steps (states.ts): a state is in
|
|
171
|
+
// scope when the ACTIVE layout's tree carries its selector. States shared by all
|
|
172
|
+
// layouts (a chat core's welcome) match everywhere; a rail-only state matches only
|
|
173
|
+
// its own arrangement, like Detail under Course.
|
|
174
|
+
const stateInLayout = (s: { when?: unknown; where?: unknown }, t: { layouts?: Record<string, unknown>; defaultLayout?: string; root?: unknown }): boolean =>
|
|
175
|
+
!t.layouts || inArrangement(t.layouts[activeLayout ?? t.defaultLayout ?? "main"] ?? t.root, s);
|
|
150
176
|
useEffect(() => {
|
|
151
177
|
const t = templates.find((x) => x.name === selected);
|
|
152
178
|
setActiveState(t?.states?.filter((s) => !(t.layouts && s.name in t.layouts!))[0] ?? null);
|
|
@@ -229,8 +255,13 @@ export function TemplatesView({
|
|
|
229
255
|
const liveStateName = useMemo(() => {
|
|
230
256
|
void storeVersion; // recompute on every store write
|
|
231
257
|
const states = selectedDef?.states ?? [];
|
|
258
|
+
// This name lights the LOCAL-state rail, so layouts (which have their own pill row
|
|
259
|
+
// and their own `activeLayout` highlight) are not candidates: a claimed `review`
|
|
260
|
+
// view must not blank the rail while a local discriminant is active beside it —
|
|
261
|
+
// the two axes compose (a review arrangement in its gathering state).
|
|
232
262
|
const claims = new Map<string, string>(); // view -> state name
|
|
233
263
|
for (const s of states) {
|
|
264
|
+
if (selectedDef?.layouts && s.name in selectedDef.layouts) continue;
|
|
234
265
|
const eq = (s.where as { eq?: unknown } | undefined)?.eq;
|
|
235
266
|
if (typeof eq === "string") claims.set(eq, s.name);
|
|
236
267
|
}
|
|
@@ -246,6 +277,13 @@ export function TemplatesView({
|
|
|
246
277
|
}
|
|
247
278
|
}
|
|
248
279
|
if (view) return claims.get(view) ?? null;
|
|
280
|
+
// LOCAL states carry their own selector (`when`, a condition on template state —
|
|
281
|
+
// railState/callState-style discriminants). Evaluate it with the RENDERER'S OWN
|
|
282
|
+
// predicate (`visible`, one condition semantics), against the template-state bag —
|
|
283
|
+
// so a pill pick, a component's write, or a live workflow echo all light the same.
|
|
284
|
+
const ts = store.getTemplateState() as Record<string, unknown>;
|
|
285
|
+
const local = states.find((s) => s.when && typeof s.when === "object" && visible(s.when as Parameters<typeof visible>[0], ts));
|
|
286
|
+
if (local) return local.name;
|
|
249
287
|
const base = store.getTimeline().length === 0 ? "isEmpty" : "hasMessages";
|
|
250
288
|
return states.find((s) => s.when === base)?.name ?? null;
|
|
251
289
|
}, [storeVersion, store, selectedDef]);
|
|
@@ -413,7 +451,9 @@ export function TemplatesView({
|
|
|
413
451
|
}, [store, live]);
|
|
414
452
|
|
|
415
453
|
const defaultsOf = (d: Def) =>
|
|
416
|
-
Object.fromEntries(
|
|
454
|
+
Object.fromEntries(
|
|
455
|
+
Object.entries(d.props ?? {}).map(([k, v]) => [k, v.preview ?? v.default ?? (v.type === "array" ? [] : v.type === "object" ? {} : "")]),
|
|
456
|
+
);
|
|
417
457
|
|
|
418
458
|
// Seed the given components into the latest response, so any slot that matches fills;
|
|
419
459
|
// unmatched slots skeleton. The SET is chosen by the caller — scoped to the selected
|
|
@@ -500,7 +540,13 @@ export function TemplatesView({
|
|
|
500
540
|
// • { field, eq } (template-state selector, e.g. defaultState="focus") → set it after seeding
|
|
501
541
|
// An explicit preview entry for the state OVERRIDES all of it — the author decided.
|
|
502
542
|
const previewMap = selectedDef?.app?.preview;
|
|
503
|
-
|
|
543
|
+
// An OBJECT preview entry is authored TEMPLATE-STATE mock data — what the workflow
|
|
544
|
+
// would have echoed (comments, railState, an anchor) — merged as-is so a state's
|
|
545
|
+
// pill previews with real content. Arrays keep the existing meaning: component seeds.
|
|
546
|
+
const stateEntry = activeState ? previewMap?.[activeState.name] : undefined;
|
|
547
|
+
const stateData = stateEntry && !Array.isArray(stateEntry) ? (stateEntry as Record<string, unknown>) : undefined;
|
|
548
|
+
const layoutEntry = activeLayout ? previewMap?.[activeLayout] : stateEntry;
|
|
549
|
+
const entry = Array.isArray(layoutEntry) ? layoutEntry : undefined;
|
|
504
550
|
const wantEmpty = !entry && !activeLayout && typeof when === "string" && when === "isEmpty";
|
|
505
551
|
// A COMPONENT is only seeded for a reaction-contract state (select.where) — the
|
|
506
552
|
// surface needs one to grab (Focus). Plain template states (welcome, conversation)
|
|
@@ -516,7 +562,11 @@ export function TemplatesView({
|
|
|
516
562
|
: [];
|
|
517
563
|
// Seed when there's preview content OR a picked state that needs a live conversation.
|
|
518
564
|
if (set.length || activeState) {
|
|
519
|
-
|
|
565
|
+
// An autoTrigger app launches by RAW trigger — nothing prints to the
|
|
566
|
+
// conversation (protocol §launch-from-UI). The mock mirrors it: components
|
|
567
|
+
// seed without the synthetic user turn, so a template that renders user turns
|
|
568
|
+
// outside a chat (a review pile) never shows "Show me this app" as content.
|
|
569
|
+
if (!selectedDef?.app?.autoTrigger) store.addUserMessage(chatIdFor(selected ?? "app"), "Show me this app");
|
|
520
570
|
// Component states need a body to grab; app declares no previewComponents →
|
|
521
571
|
// fall back to the SAMPLE ANSWER (the Markdown component's own default content)
|
|
522
572
|
// so the conversation still previews a real assistant turn.
|
|
@@ -534,6 +584,9 @@ export function TemplatesView({
|
|
|
534
584
|
} else if (typeof when === "string" && when !== "isEmpty" && when !== "hasMessages") {
|
|
535
585
|
store.mergeTemplateState({ [when]: true });
|
|
536
586
|
}
|
|
587
|
+
// Authored template-state mock (object preview entry) — merged AFTER the selector so
|
|
588
|
+
// the author's data wins (it may refine the same keys, e.g. railState + comments).
|
|
589
|
+
if (stateData) store.mergeTemplateState(stateData);
|
|
537
590
|
// Reaction-contract activation (STATE_MODEL §5b): the surface (or the layout pill —
|
|
538
591
|
// same selector) reacts to COMPONENT state, so activate it the way the runtime
|
|
539
592
|
// does — put a seeded component INTO that view. Same write the component's own
|
|
@@ -578,7 +631,7 @@ export function TemplatesView({
|
|
|
578
631
|
the rail keeps only the template's LOCAL states. */}
|
|
579
632
|
{!live && selected === t.name && (t.states?.length ?? 0) > 0 && (
|
|
580
633
|
<div className="my-0.5 ml-2 flex flex-col gap-px border-l border-gray-700 pl-2">
|
|
581
|
-
{t.states!.filter((s) => !(t.layouts && s.name in t.layouts!)).map((s) => (
|
|
634
|
+
{t.states!.filter((s) => !(t.layouts && s.name in t.layouts!)).filter((s) => stateInLayout(s, t)).map((s) => (
|
|
582
635
|
<button
|
|
583
636
|
key={s.name}
|
|
584
637
|
onClick={() => {
|
package/screens/states.ts
CHANGED
|
@@ -1,32 +1,75 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Layer resolution + state activation — shared by the Templates and Components views.
|
|
3
3
|
*
|
|
4
4
|
* A component's `states/` files are its Switch-case layers: the state NAME is the
|
|
5
5
|
* case key, and the Switch's `on` field is the discriminant that selects it
|
|
6
6
|
* (UNOVERSE_LAYERS.md §7). To show a state, write `{ [discriminant]: name }` into
|
|
7
7
|
* the component's own slice — exactly what the component's own buttons do.
|
|
8
|
+
*
|
|
9
|
+
* SCOPE lives here too (UNOVERSE_LAYERS.md §3c): a layer belongs to the arrangement
|
|
10
|
+
* that DRAWS it — a face's steps are the Switches on the component's own keys inside
|
|
11
|
+
* that face; a template state belongs to the layouts whose trees carry its selector.
|
|
12
|
+
* The structure is the declaration; both views derive, neither lists.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Walk every object node of a definition tree until `visit` says stop (returns true). */
|
|
16
|
+
function walkTree(n: unknown, visit: (node: Record<string, unknown>) => boolean | void): boolean {
|
|
17
|
+
if (!n || typeof n !== "object") return false;
|
|
18
|
+
if (Array.isArray(n)) return n.some((c) => walkTree(c, visit));
|
|
19
|
+
const o = n as Record<string, unknown>;
|
|
20
|
+
if (visit(o)) return true;
|
|
21
|
+
return Object.values(o).some((v) => walkTree(v, visit));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The subtree a face draws: the root's `Switch on defaultState` case of that name. */
|
|
25
|
+
export function faceOf(root: unknown, face: string): unknown {
|
|
26
|
+
let found: unknown;
|
|
27
|
+
walkTree(root, (o) => {
|
|
28
|
+
if (o.type === "Switch" && o.on === "defaultState" && o.cases && typeof o.cases === "object" && (o.cases as Record<string, unknown>)[face]) {
|
|
29
|
+
found = (o.cases as Record<string, unknown>)[face];
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
return found;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The STEPS a subtree owns: every case of a `Switch` that turns on one of the thing's
|
|
38
|
+
* OWN state keys (`defaultState` excluded — that Switch picks faces, not steps).
|
|
39
|
+
* Switches on data fields (`kind`, a nested component's key) are content selection,
|
|
40
|
+
* not layers — which is why the caller passes the declared keys.
|
|
41
|
+
*/
|
|
42
|
+
export function switchLayers(tree: unknown, ownKeys: ReadonlySet<string>): { on: string; name: string }[] {
|
|
43
|
+
const out: { on: string; name: string }[] = [];
|
|
44
|
+
walkTree(tree, (o) => {
|
|
45
|
+
if (o.type === "Switch" && typeof o.on === "string" && o.on !== "defaultState" && ownKeys.has(o.on) && o.cases && typeof o.cases === "object") {
|
|
46
|
+
for (const k of Object.keys(o.cases)) if (k !== "default" && !out.some((s) => s.name === k)) out.push({ on: o.on, name: k });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Does an arrangement DRAW this state? Includes are inlined at serve time, so the
|
|
54
|
+
* selector authored on a state file (`visibleWhen` for a local layer, `select.where`
|
|
55
|
+
* for a reaction surface) appears verbatim inside every layout that `$include`s it.
|
|
56
|
+
* A state with no selector has nothing to scope by — always in.
|
|
8
57
|
*/
|
|
58
|
+
export function inArrangement(tree: unknown, state: { when?: unknown; where?: unknown }): boolean {
|
|
59
|
+
const sel = JSON.stringify(state.when ?? state.where ?? null);
|
|
60
|
+
if (sel === "null") return true;
|
|
61
|
+
return walkTree(tree, (o) => JSON.stringify(o.visibleWhen ?? null) === sel || JSON.stringify((o.select as { where?: unknown } | undefined)?.where ?? null) === sel);
|
|
62
|
+
}
|
|
9
63
|
|
|
10
64
|
/** Find the discriminant field whose Switch has a case named `stateName`. */
|
|
11
65
|
export function discriminantFor(root: unknown, stateName: string): string | null {
|
|
12
66
|
let found: string | null = null;
|
|
13
|
-
|
|
14
|
-
if (
|
|
15
|
-
if (Array.isArray(n)) return n.forEach(walk);
|
|
16
|
-
const o = n as Record<string, unknown>;
|
|
17
|
-
if (
|
|
18
|
-
o.type === "Switch" &&
|
|
19
|
-
typeof o.on === "string" &&
|
|
20
|
-
o.cases &&
|
|
21
|
-
typeof o.cases === "object" &&
|
|
22
|
-
Object.prototype.hasOwnProperty.call(o.cases, stateName)
|
|
23
|
-
) {
|
|
67
|
+
walkTree(root, (o) => {
|
|
68
|
+
if (o.type === "Switch" && typeof o.on === "string" && o.cases && typeof o.cases === "object" && Object.prototype.hasOwnProperty.call(o.cases, stateName)) {
|
|
24
69
|
found = o.on;
|
|
25
|
-
return;
|
|
70
|
+
return true;
|
|
26
71
|
}
|
|
27
|
-
|
|
28
|
-
};
|
|
29
|
-
walk(root);
|
|
72
|
+
});
|
|
30
73
|
return found;
|
|
31
74
|
}
|
|
32
75
|
|
|
@@ -130,18 +130,96 @@ const inspectId = (node: UnoverseNode): string | undefined => {
|
|
|
130
130
|
return typeof id === "string" ? id : undefined;
|
|
131
131
|
};
|
|
132
132
|
|
|
133
|
-
export function BoxView({ node, data, theme, children }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; children?: ReactNode }): ReactNode {
|
|
133
|
+
export function BoxView({ node, data, theme, children, onAction }: { node: UnoverseNode; data: Record<string, unknown>; theme: ResolvedTheme; children?: ReactNode; onAction?: ActionHandler }): ReactNode {
|
|
134
134
|
const { className, styleEls, style } = nodeChrome(node, data, theme);
|
|
135
135
|
const scrollRef = useAutoScrollToEnd(node.autoScroll === true);
|
|
136
136
|
const revealRef = useRevealWhenStuck(node.revealOnStick === true);
|
|
137
|
+
const sel = (node as { selection?: { label?: string; action?: unknown } }).selection;
|
|
138
|
+
const { selRef, pick, clearPick } = useTextSelection(!!sel);
|
|
139
|
+
// A selection host must anchor its floating affordance; default to relative only when
|
|
140
|
+
// the definition declared no position of its own (sticky/absolute roots keep theirs).
|
|
141
|
+
const hostStyle = sel && !(style as CSSProperties).position ? ({ ...style, position: "relative" } as CSSProperties) : style;
|
|
137
142
|
return (
|
|
138
|
-
<div ref={scrollRef ?? revealRef} className={className} style={
|
|
143
|
+
<div ref={scrollRef ?? revealRef ?? selRef} className={className} style={hostStyle} data-unoverse-id={inspectId(node)}>
|
|
139
144
|
{styleEls}
|
|
140
145
|
{children}
|
|
146
|
+
{sel && pick && (
|
|
147
|
+
<button
|
|
148
|
+
style={{
|
|
149
|
+
position: "absolute",
|
|
150
|
+
top: pick.top,
|
|
151
|
+
left: pick.left,
|
|
152
|
+
transform: "translate(-50%, -100%)",
|
|
153
|
+
zIndex: 40,
|
|
154
|
+
whiteSpace: "nowrap",
|
|
155
|
+
...styleToCss(((theme as { selection?: { chip?: Record<string, unknown> } }).selection?.chip ?? SELECTION_CHIP_BUILTIN) as Record<string, unknown>, theme),
|
|
156
|
+
}}
|
|
157
|
+
onMouseDown={(e) => e.preventDefault() /* keep the selection alive through the click */}
|
|
158
|
+
onClick={() => {
|
|
159
|
+
onAction?.((sel.action as Parameters<ActionHandler>[0]) ?? "select", { ...data, selection: pick.text });
|
|
160
|
+
clearPick();
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
{sel.label ?? "Comment"}
|
|
164
|
+
</button>
|
|
165
|
+
)}
|
|
141
166
|
</div>
|
|
142
167
|
);
|
|
143
168
|
}
|
|
144
169
|
|
|
170
|
+
// The selection chip's look: a SERVED recipe (`theme.selection.chip`) when the org styles
|
|
171
|
+
// one; else this neutral fallback of token NAMES resolved against the same theme — the
|
|
172
|
+
// skeleton-shimmer pattern. The SDK still authors no values, only names.
|
|
173
|
+
const SELECTION_CHIP_BUILTIN: Record<string, unknown> = {
|
|
174
|
+
background: "action.primary",
|
|
175
|
+
color: "text.inverse",
|
|
176
|
+
font: "body.sm",
|
|
177
|
+
radius: "full",
|
|
178
|
+
padding: ["1", "3"],
|
|
179
|
+
shadow: "lg",
|
|
180
|
+
border: "none",
|
|
181
|
+
cursor: "pointer",
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Generic "text was selected here" behavior (opt-in via `node.selection`). The renderer is
|
|
186
|
+
* the ONLY layer that can know a reader selected text — definitions cannot express it —
|
|
187
|
+
* so, like `skeleton` and `autoScroll`, capture is SDK behavior while every consequence
|
|
188
|
+
* stays authored: the node declares the ACTION (fired with the selected text in scope as
|
|
189
|
+
* `{{selection}}`) and the chip's look is served (`theme.selection.chip`). Selection is
|
|
190
|
+
* read from the node's own root (shadow-DOM aware); collapsing it dismisses the chip.
|
|
191
|
+
*/
|
|
192
|
+
function useTextSelection(enabled: boolean) {
|
|
193
|
+
const selRef = useRef<HTMLDivElement | null>(null);
|
|
194
|
+
const [pick, setPick] = useState<{ text: string; top: number; left: number } | null>(null);
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (!enabled) return;
|
|
197
|
+
const el = selRef.current;
|
|
198
|
+
if (!el) return;
|
|
199
|
+
const read = () => {
|
|
200
|
+
const root = el.getRootNode() as Document | ShadowRoot;
|
|
201
|
+
const s = (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection();
|
|
202
|
+
if (!s || s.isCollapsed || s.rangeCount === 0) return setPick(null);
|
|
203
|
+
const range = s.getRangeAt(0);
|
|
204
|
+
if (!el.contains(range.commonAncestorContainer)) return setPick(null);
|
|
205
|
+
const text = s.toString().trim();
|
|
206
|
+
if (!text) return setPick(null);
|
|
207
|
+
const r = range.getBoundingClientRect();
|
|
208
|
+
const host = el.getBoundingClientRect();
|
|
209
|
+
setPick({ text, top: r.top - host.top, left: Math.min(Math.max(0, r.left - host.left + r.width / 2), host.width) });
|
|
210
|
+
};
|
|
211
|
+
const up = () => window.setTimeout(read, 0); // after the browser settles the selection
|
|
212
|
+
// Listen on the DOCUMENT, not the host: a drag-select routinely ends with the mouse
|
|
213
|
+
// released outside the host (over a rail, past the edge), and mouse events are
|
|
214
|
+
// composed, so they cross the shadow boundary to reach it. `read` still guards with
|
|
215
|
+
// `el.contains(...)`, so only selections inside this host raise the chip.
|
|
216
|
+
const doc = el.ownerDocument;
|
|
217
|
+
doc.addEventListener("mouseup", up);
|
|
218
|
+
return () => doc.removeEventListener("mouseup", up);
|
|
219
|
+
}, [enabled]);
|
|
220
|
+
return { selRef: enabled ? selRef : undefined, pick, clearPick: () => setPick(null) };
|
|
221
|
+
}
|
|
222
|
+
|
|
145
223
|
/**
|
|
146
224
|
* Generic "stick to the newest content" behavior for a scroll container (opt-in via
|
|
147
225
|
* `node.autoScroll`). Smooth-scrolls to the bottom whenever content grows — a new turn
|
|
@@ -364,6 +442,27 @@ export function InputView({ node, data, theme, onAction }: { node: UnoverseNode;
|
|
|
364
442
|
if (field == null) setLocal("");
|
|
365
443
|
};
|
|
366
444
|
const { style, disabled } = nodeChrome(node, data, theme);
|
|
445
|
+
// `multiline: true` — the same irreducible input as a `<textarea>` (critique, notes —
|
|
446
|
+
// anything longer than one line). Enter inserts a newline; Cmd/Ctrl+Enter submits.
|
|
447
|
+
// Same element class, no chrome, no new primitive.
|
|
448
|
+
if ((node as { multiline?: boolean }).multiline === true) {
|
|
449
|
+
return (
|
|
450
|
+
<textarea
|
|
451
|
+
style={style}
|
|
452
|
+
disabled={disabled}
|
|
453
|
+
value={value}
|
|
454
|
+
placeholder={node.placeholder}
|
|
455
|
+
rows={(node as { rows?: number }).rows ?? 3}
|
|
456
|
+
onChange={(e) => setValue(e.target.value)}
|
|
457
|
+
onKeyDown={(e) => {
|
|
458
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
459
|
+
e.preventDefault();
|
|
460
|
+
submit();
|
|
461
|
+
}
|
|
462
|
+
}}
|
|
463
|
+
/>
|
|
464
|
+
);
|
|
465
|
+
}
|
|
367
466
|
return (
|
|
368
467
|
<input
|
|
369
468
|
style={style}
|
|
@@ -173,7 +173,7 @@ export function renderNode(
|
|
|
173
173
|
case "Row":
|
|
174
174
|
case "Column":
|
|
175
175
|
return (
|
|
176
|
-
<BoxView key={key} node={node} data={data} theme={theme}>
|
|
176
|
+
<BoxView key={key} node={node} data={data} theme={theme} onAction={onAction}>
|
|
177
177
|
{kids}
|
|
178
178
|
</BoxView>
|
|
179
179
|
);
|