@moloodev/layout-sdk 0.0.0-stage → 0.1.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/README.md +148 -2
- package/package.json +50 -3
- package/src/basic.tsx +366 -0
- package/src/catalog/index.ts +3 -0
- package/src/catalog/resolve.ts +195 -0
- package/src/catalog/validate.ts +146 -0
- package/src/catalog-format/contract.ts +131 -0
- package/src/catalog-format/fixtures.ts +148 -0
- package/src/catalog-format/index.ts +10 -0
- package/src/catalog-format/schema.ts +146 -0
- package/src/catalog-format/value-schema.ts +110 -0
- package/src/compose.tsx +78 -0
- package/src/examples/components.tsx +156 -0
- package/src/examples/index.ts +1 -0
- package/src/examples/packages.ts +42 -0
- package/src/index.ts +18 -0
- package/src/layout-format/bind.ts +88 -0
- package/src/layout-format/contract.ts +146 -0
- package/src/layout-format/edit.ts +278 -0
- package/src/layout-format/index.ts +18 -0
- package/src/layout-format/schema.ts +97 -0
- package/src/layout.ts +74 -0
- package/src/react.tsx +34 -0
- package/src/render-fixture.ts +51 -0
- package/src/runtime.tsx +165 -0
- package/src/three.tsx +19 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { fieldName, jsonValue, objectSchema } from "./value-schema";
|
|
3
|
+
|
|
4
|
+
export const CATALOG_SCHEMA_VERSION = 1;
|
|
5
|
+
const componentKey = z
|
|
6
|
+
.string()
|
|
7
|
+
.regex(/^[a-z][a-z0-9-]*\.[A-Za-z][A-Za-z0-9]*$/);
|
|
8
|
+
const targetId = z.string().regex(/^[a-z][a-z0-9-]*$/);
|
|
9
|
+
const identifier = z
|
|
10
|
+
.string()
|
|
11
|
+
.min(1)
|
|
12
|
+
.refine((value) => value.trim() === value);
|
|
13
|
+
const version = z.number().int().positive();
|
|
14
|
+
|
|
15
|
+
const resourceSchema = z.strictObject({
|
|
16
|
+
assetId: identifier,
|
|
17
|
+
assetType: z.string().regex(/^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const implementationSchema = z.strictObject({
|
|
21
|
+
package: z.string().regex(/^(?:@[a-z0-9-]+\/)?[a-z0-9][a-z0-9._-]*$/),
|
|
22
|
+
version: z
|
|
23
|
+
.string()
|
|
24
|
+
.regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/),
|
|
25
|
+
entry: fieldName,
|
|
26
|
+
abi: z.literal(1),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const contractSchema = z
|
|
30
|
+
.strictObject({
|
|
31
|
+
version,
|
|
32
|
+
props: objectSchema,
|
|
33
|
+
slots: z.record(
|
|
34
|
+
fieldName,
|
|
35
|
+
z.strictObject({
|
|
36
|
+
/** Empty accepts means any component supported by the current target. */
|
|
37
|
+
accepts: z.array(componentKey),
|
|
38
|
+
}),
|
|
39
|
+
),
|
|
40
|
+
/** Explicit renderer boundaries; these are not native child slots. */
|
|
41
|
+
surfaceSlots: z
|
|
42
|
+
.record(fieldName, z.strictObject({ target: targetId }))
|
|
43
|
+
.optional(),
|
|
44
|
+
/** Named json-render events; payloads remain in state/action params. */
|
|
45
|
+
events: z.array(fieldName),
|
|
46
|
+
resources: z.record(
|
|
47
|
+
fieldName,
|
|
48
|
+
z.strictObject({ assetType: resourceSchema.shape.assetType }),
|
|
49
|
+
),
|
|
50
|
+
})
|
|
51
|
+
.refine((value) => new Set(value.events).size === value.events.length, {
|
|
52
|
+
message: "Duplicate event",
|
|
53
|
+
path: ["events"],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const patchFields = {
|
|
57
|
+
defaults: z.record(fieldName, jsonValue),
|
|
58
|
+
resources: z.record(fieldName, resourceSchema),
|
|
59
|
+
implementations: z.record(targetId, implementationSchema),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const transformAxes = z
|
|
63
|
+
.strictObject({
|
|
64
|
+
x: fieldName.optional(),
|
|
65
|
+
y: fieldName.optional(),
|
|
66
|
+
z: fieldName.optional(),
|
|
67
|
+
})
|
|
68
|
+
.refine((axes) => Boolean(axes.x || axes.y || axes.z), {
|
|
69
|
+
message: "At least one transform axis is required",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const transformSchema = z
|
|
73
|
+
.strictObject({
|
|
74
|
+
position: transformAxes.optional(),
|
|
75
|
+
rotation: transformAxes.optional(),
|
|
76
|
+
scale: transformAxes.optional(),
|
|
77
|
+
})
|
|
78
|
+
.refine((value) => Boolean(value.position || value.rotation || value.scale), {
|
|
79
|
+
message: "At least one transform operation is required",
|
|
80
|
+
})
|
|
81
|
+
.refine(
|
|
82
|
+
(value) => {
|
|
83
|
+
const props = Object.values(value).flatMap((axes) =>
|
|
84
|
+
Object.values(axes ?? {}).filter((prop) => prop !== undefined),
|
|
85
|
+
);
|
|
86
|
+
return new Set(props).size === props.length;
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
message: "Transform axes must use distinct properties",
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const editorSchema = z.strictObject({
|
|
94
|
+
category: z.string(),
|
|
95
|
+
/** Per-target hints; canvas adapters interpret these, never component names. */
|
|
96
|
+
layout: z.record(targetId, z.enum(["leaf", "flow", "grid", "spatial"])),
|
|
97
|
+
/** Direct numeric props controlling root extents. DOM uses border-box CSS pixels. */
|
|
98
|
+
resize: z
|
|
99
|
+
.record(
|
|
100
|
+
targetId,
|
|
101
|
+
z
|
|
102
|
+
.strictObject({
|
|
103
|
+
width: fieldName.optional(),
|
|
104
|
+
height: fieldName.optional(),
|
|
105
|
+
})
|
|
106
|
+
.refine((axes) => Boolean(axes.width || axes.height), {
|
|
107
|
+
message: "At least one resize axis is required",
|
|
108
|
+
})
|
|
109
|
+
.refine((axes) => !axes.width || axes.width !== axes.height, {
|
|
110
|
+
message: "Resize axes must use distinct properties",
|
|
111
|
+
}),
|
|
112
|
+
)
|
|
113
|
+
.optional(),
|
|
114
|
+
/** Local position, Euler XYZ rotation in radians, and unitless scale. */
|
|
115
|
+
transform: z.record(targetId, transformSchema).optional(),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const definitionSchema = z.strictObject({
|
|
119
|
+
key: componentKey,
|
|
120
|
+
contract: contractSchema,
|
|
121
|
+
description: z.string(),
|
|
122
|
+
...patchFields,
|
|
123
|
+
editor: editorSchema,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const overrideSchema = z.strictObject({
|
|
127
|
+
overrides: componentKey,
|
|
128
|
+
requiresContract: version,
|
|
129
|
+
defaults: patchFields.defaults.optional(),
|
|
130
|
+
resources: patchFields.resources.optional(),
|
|
131
|
+
implementations: patchFields.implementations.optional(),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
export const contentSchema = z.strictObject({
|
|
135
|
+
version: z.literal(CATALOG_SCHEMA_VERSION),
|
|
136
|
+
/** Dependencies must appear earlier in the explicit stack; no implicit expansion. */
|
|
137
|
+
requires: z.array(identifier),
|
|
138
|
+
entries: z.array(z.union([definitionSchema, overrideSchema])),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
export type Content = z.infer<typeof contentSchema>;
|
|
142
|
+
export type ComponentContract = z.infer<typeof contractSchema>;
|
|
143
|
+
export type ComponentDefinition = z.infer<typeof definitionSchema>;
|
|
144
|
+
|
|
145
|
+
export type ImplementationReference = z.infer<typeof implementationSchema>;
|
|
146
|
+
export type ResourceReference = z.infer<typeof resourceSchema>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/** A closed JSON Schema subset. Unsupported keywords fail instead of being ignored. */
|
|
4
|
+
export type ValueSchema =
|
|
5
|
+
| { type: "string"; enum?: string[]; minLength?: number; maxLength?: number }
|
|
6
|
+
| { type: "number" | "integer"; minimum?: number; maximum?: number }
|
|
7
|
+
| { type: "boolean" }
|
|
8
|
+
| { type: "null" }
|
|
9
|
+
| { type: "array"; items: ValueSchema; minItems?: number; maxItems?: number }
|
|
10
|
+
| ObjectSchema;
|
|
11
|
+
|
|
12
|
+
export type ObjectSchema = {
|
|
13
|
+
type: "object";
|
|
14
|
+
properties: Record<string, ValueSchema>;
|
|
15
|
+
required: string[];
|
|
16
|
+
additionalProperties: false;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const fieldName = z
|
|
20
|
+
.string()
|
|
21
|
+
.regex(/^[A-Za-z][A-Za-z0-9_-]*$/)
|
|
22
|
+
.refine(
|
|
23
|
+
(name) => name !== "prototype" && !Object.hasOwn(Object.prototype, name),
|
|
24
|
+
);
|
|
25
|
+
const length = z.number().int().nonnegative();
|
|
26
|
+
|
|
27
|
+
const valueSchema: z.ZodType<ValueSchema> = z.lazy(() =>
|
|
28
|
+
z
|
|
29
|
+
.union([
|
|
30
|
+
z.strictObject({
|
|
31
|
+
type: z.literal("string"),
|
|
32
|
+
enum: z.array(z.string()).min(1).optional(),
|
|
33
|
+
minLength: length.optional(),
|
|
34
|
+
maxLength: length.optional(),
|
|
35
|
+
}),
|
|
36
|
+
z.strictObject({
|
|
37
|
+
type: z.enum(["number", "integer"]),
|
|
38
|
+
minimum: z.number().optional(),
|
|
39
|
+
maximum: z.number().optional(),
|
|
40
|
+
}),
|
|
41
|
+
z.strictObject({ type: z.literal("boolean") }),
|
|
42
|
+
z.strictObject({ type: z.literal("null") }),
|
|
43
|
+
z.strictObject({
|
|
44
|
+
type: z.literal("array"),
|
|
45
|
+
items: valueSchema,
|
|
46
|
+
minItems: length.optional(),
|
|
47
|
+
maxItems: length.optional(),
|
|
48
|
+
}),
|
|
49
|
+
objectSchema,
|
|
50
|
+
])
|
|
51
|
+
.superRefine((value, context) => {
|
|
52
|
+
const pairs = [
|
|
53
|
+
["minLength", "maxLength"],
|
|
54
|
+
["minItems", "maxItems"],
|
|
55
|
+
["minimum", "maximum"],
|
|
56
|
+
] as const;
|
|
57
|
+
const bounds = value as Record<string, unknown>;
|
|
58
|
+
for (const [min, max] of pairs) {
|
|
59
|
+
if (
|
|
60
|
+
typeof bounds[min] === "number" &&
|
|
61
|
+
typeof bounds[max] === "number" &&
|
|
62
|
+
bounds[min] > bounds[max]
|
|
63
|
+
)
|
|
64
|
+
context.addIssue({
|
|
65
|
+
code: "custom",
|
|
66
|
+
message: "Invalid bounds",
|
|
67
|
+
path: [max],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
export const objectSchema: z.ZodType<ObjectSchema> = z.lazy(() =>
|
|
74
|
+
z
|
|
75
|
+
.strictObject({
|
|
76
|
+
type: z.literal("object"),
|
|
77
|
+
properties: z.record(fieldName, valueSchema),
|
|
78
|
+
required: z.array(fieldName),
|
|
79
|
+
additionalProperties: z.literal(false),
|
|
80
|
+
})
|
|
81
|
+
.superRefine((value, context) => {
|
|
82
|
+
if (
|
|
83
|
+
new Set(value.required).size !== value.required.length ||
|
|
84
|
+
value.required.some((key) => !Object.hasOwn(value.properties, key))
|
|
85
|
+
)
|
|
86
|
+
context.addIssue({
|
|
87
|
+
code: "custom",
|
|
88
|
+
message: "Required fields must be unique declared properties",
|
|
89
|
+
path: ["required"],
|
|
90
|
+
});
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
/** Only call with a schema accepted by valueSchema/objectSchema. No refs or code. */
|
|
95
|
+
export function valueValidator(schema: ValueSchema): z.ZodType {
|
|
96
|
+
return z.fromJSONSchema(schema);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type Json =
|
|
100
|
+
null | boolean | number | string | Json[] | { [key: string]: Json };
|
|
101
|
+
export const jsonValue: z.ZodType<Json> = z.lazy(() =>
|
|
102
|
+
z.union([
|
|
103
|
+
z.null(),
|
|
104
|
+
z.boolean(),
|
|
105
|
+
z.number(),
|
|
106
|
+
z.string(),
|
|
107
|
+
z.array(jsonValue),
|
|
108
|
+
z.record(z.string(), jsonValue),
|
|
109
|
+
]),
|
|
110
|
+
);
|
package/src/compose.tsx
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createElement, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
import type { ComponentRenderProps } from "@json-render/react";
|
|
3
|
+
import type { PreparedLayout } from "./layout";
|
|
4
|
+
import type { PreparedSurface, RuntimeComponentProps } from "./runtime";
|
|
5
|
+
|
|
6
|
+
export type ElementIdentity = {
|
|
7
|
+
surface: string;
|
|
8
|
+
element: string;
|
|
9
|
+
type: string;
|
|
10
|
+
target: string;
|
|
11
|
+
};
|
|
12
|
+
export type ElementObserverProps = {
|
|
13
|
+
identity: ElementIdentity;
|
|
14
|
+
nativeSlots: readonly string[];
|
|
15
|
+
render(
|
|
16
|
+
rootRef: NonNullable<RuntimeComponentProps["rootRef"]>,
|
|
17
|
+
slotRefs?: RuntimeComponentProps["slotRefs"],
|
|
18
|
+
): ReactNode;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Per-element registry entries keep mount identity out of saved props and native children. */
|
|
22
|
+
export function composeSurfaces(
|
|
23
|
+
layout: PreparedLayout,
|
|
24
|
+
boundary: (surfaceId: string, parentTarget: string) => ReactNode,
|
|
25
|
+
Observer?: ComponentType<ElementObserverProps>,
|
|
26
|
+
): Record<string, PreparedSurface> {
|
|
27
|
+
return Object.fromEntries(
|
|
28
|
+
Object.entries(layout.surfaces).map(([id, surface]) => {
|
|
29
|
+
const spec = structuredClone(surface.spec);
|
|
30
|
+
const registry: PreparedSurface["registry"] = Object.create(null);
|
|
31
|
+
for (const [elementId, element] of Object.entries(spec.elements)) {
|
|
32
|
+
const type = element.type;
|
|
33
|
+
const Component = surface.registry[type]!;
|
|
34
|
+
const identity = {
|
|
35
|
+
surface: id,
|
|
36
|
+
element: elementId,
|
|
37
|
+
type,
|
|
38
|
+
target: surface.target,
|
|
39
|
+
};
|
|
40
|
+
const nativeSlots = Object.freeze(
|
|
41
|
+
Object.keys(layout.catalog.components[type]!.contract.slots),
|
|
42
|
+
);
|
|
43
|
+
// The alias exists only in the prepared copy; it is not a component contract key.
|
|
44
|
+
element.type = elementId;
|
|
45
|
+
registry[elementId] = (props: ComponentRenderProps) => {
|
|
46
|
+
const surfaces = Object.fromEntries(
|
|
47
|
+
Object.entries(surface.mounts[elementId] ?? {}).map(
|
|
48
|
+
([slot, child]) => [slot, boundary(child, surface.target)],
|
|
49
|
+
),
|
|
50
|
+
);
|
|
51
|
+
const render = (
|
|
52
|
+
rootRef?: RuntimeComponentProps["rootRef"],
|
|
53
|
+
slotRefs?: RuntimeComponentProps["slotRefs"],
|
|
54
|
+
) =>
|
|
55
|
+
createElement(Component, {
|
|
56
|
+
...props,
|
|
57
|
+
element: { ...props.element, type },
|
|
58
|
+
surfaces,
|
|
59
|
+
rootRef,
|
|
60
|
+
slotRefs:
|
|
61
|
+
slotRefs &&
|
|
62
|
+
Object.fromEntries(
|
|
63
|
+
nativeSlots.flatMap((slot) =>
|
|
64
|
+
Object.hasOwn(slotRefs, slot)
|
|
65
|
+
? [[slot, slotRefs[slot]]]
|
|
66
|
+
: [],
|
|
67
|
+
),
|
|
68
|
+
),
|
|
69
|
+
} as RuntimeComponentProps);
|
|
70
|
+
return Observer
|
|
71
|
+
? createElement(Observer, { identity, nativeSlots, render })
|
|
72
|
+
: render();
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return [id, { target: surface.target, spec, registry }];
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Suspense, useMemo } from "react";
|
|
2
|
+
import { useLoader, type ThreeEvent } from "@react-three/fiber";
|
|
3
|
+
import { TextureLoader } from "three";
|
|
4
|
+
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
5
|
+
import type { RuntimeComponentProps } from "../runtime";
|
|
6
|
+
|
|
7
|
+
const tileStyles = `
|
|
8
|
+
.moloo-layout-example-tile {
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
border: 3px solid transparent;
|
|
11
|
+
}
|
|
12
|
+
@media (forced-colors: active) {
|
|
13
|
+
.moloo-layout-example-tile { border-color: ButtonText; }
|
|
14
|
+
.moloo-layout-example-tile[aria-pressed="true"] {
|
|
15
|
+
border-style: double;
|
|
16
|
+
border-color: Highlight;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
export function DomTile({
|
|
22
|
+
element,
|
|
23
|
+
emit,
|
|
24
|
+
resources,
|
|
25
|
+
rootRef,
|
|
26
|
+
}: RuntimeComponentProps) {
|
|
27
|
+
const { color, selected, size, label } = element.props as {
|
|
28
|
+
color: string;
|
|
29
|
+
label: string;
|
|
30
|
+
selected: boolean;
|
|
31
|
+
size: number;
|
|
32
|
+
};
|
|
33
|
+
return (
|
|
34
|
+
<>
|
|
35
|
+
<style
|
|
36
|
+
href="moloo-layout-example-tile"
|
|
37
|
+
precedence="moloo-layout-examples"
|
|
38
|
+
>
|
|
39
|
+
{tileStyles}
|
|
40
|
+
</style>
|
|
41
|
+
<button
|
|
42
|
+
ref={rootRef}
|
|
43
|
+
className="moloo-layout-example-tile"
|
|
44
|
+
type="button"
|
|
45
|
+
aria-label={label}
|
|
46
|
+
aria-pressed={selected}
|
|
47
|
+
style={{
|
|
48
|
+
width: size * 48,
|
|
49
|
+
height: size * 48,
|
|
50
|
+
backgroundColor: color,
|
|
51
|
+
backgroundImage: `url("${resources.face!.url}")`,
|
|
52
|
+
backgroundSize: "cover",
|
|
53
|
+
boxShadow: selected ? "inset 0 0 0 3px currentColor" : undefined,
|
|
54
|
+
}}
|
|
55
|
+
onClick={() => emit("press")}
|
|
56
|
+
/>
|
|
57
|
+
</>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function WinterDomTile({ rootRef, ...props }: RuntimeComponentProps) {
|
|
62
|
+
return (
|
|
63
|
+
<span ref={rootRef} data-season="winter">
|
|
64
|
+
<DomTile {...props} />
|
|
65
|
+
</span>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function DomStack({ children, rootRef }: RuntimeComponentProps) {
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
ref={rootRef}
|
|
73
|
+
style={{ display: "flex", flexDirection: "column", gap: 8 }}
|
|
74
|
+
>
|
|
75
|
+
{children}
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function TexturedTile({
|
|
81
|
+
element,
|
|
82
|
+
emit,
|
|
83
|
+
resources,
|
|
84
|
+
rootRef,
|
|
85
|
+
}: RuntimeComponentProps) {
|
|
86
|
+
const texture = useLoader(TextureLoader, resources.face!.url);
|
|
87
|
+
const { color, selected, size } = element.props as {
|
|
88
|
+
color: string;
|
|
89
|
+
selected: boolean;
|
|
90
|
+
size: number;
|
|
91
|
+
};
|
|
92
|
+
return (
|
|
93
|
+
<mesh
|
|
94
|
+
ref={rootRef}
|
|
95
|
+
name="puzzle.Tile"
|
|
96
|
+
scale={size}
|
|
97
|
+
onClick={(event) => {
|
|
98
|
+
event.stopPropagation();
|
|
99
|
+
emit("press");
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<boxGeometry args={[1, 1, 0.15]} />
|
|
103
|
+
<meshBasicMaterial map={texture} color={color} wireframe={selected} />
|
|
104
|
+
</mesh>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function ThreeTile(props: RuntimeComponentProps) {
|
|
109
|
+
return (
|
|
110
|
+
<Suspense fallback={null}>
|
|
111
|
+
<TexturedTile {...props} />
|
|
112
|
+
</Suspense>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function ThreeGroup({ children, rootRef }: RuntimeComponentProps) {
|
|
117
|
+
return <group ref={rootRef}>{children}</group>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function Snowfall({ children, rootRef }: RuntimeComponentProps) {
|
|
121
|
+
return (
|
|
122
|
+
<group ref={rootRef} name="winter.Snowfall">
|
|
123
|
+
{[-1, 0, 1].map((x) => (
|
|
124
|
+
<mesh key={x} position={[x, 1.5, 0]}>
|
|
125
|
+
<sphereGeometry args={[0.05, 8, 8]} />
|
|
126
|
+
<meshBasicMaterial color="white" />
|
|
127
|
+
</mesh>
|
|
128
|
+
))}
|
|
129
|
+
{children}
|
|
130
|
+
</group>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function LoadedBuilding({ emit, resources, rootRef }: RuntimeComponentProps) {
|
|
135
|
+
const model = useLoader(GLTFLoader, resources.model!.url);
|
|
136
|
+
// Instances must not share Object3D parentage. Loaded geometry/materials remain cached.
|
|
137
|
+
const instance = useMemo(() => model.scene.clone(true), [model.scene]);
|
|
138
|
+
return (
|
|
139
|
+
<primitive
|
|
140
|
+
ref={rootRef}
|
|
141
|
+
object={instance}
|
|
142
|
+
onClick={(event: ThreeEvent<MouseEvent>) => {
|
|
143
|
+
event.stopPropagation();
|
|
144
|
+
emit("press");
|
|
145
|
+
}}
|
|
146
|
+
/>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function Building(props: RuntimeComponentProps) {
|
|
151
|
+
return (
|
|
152
|
+
<Suspense fallback={null}>
|
|
153
|
+
<LoadedBuilding {...props} />
|
|
154
|
+
</Suspense>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { examplePackages } from "./packages";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildingContract,
|
|
3
|
+
groupContract,
|
|
4
|
+
tileContract,
|
|
5
|
+
} from "../catalog-format/fixtures";
|
|
6
|
+
import type { ImplementationPackage } from "../runtime";
|
|
7
|
+
import {
|
|
8
|
+
Building,
|
|
9
|
+
DomStack,
|
|
10
|
+
DomTile,
|
|
11
|
+
Snowfall,
|
|
12
|
+
ThreeGroup,
|
|
13
|
+
ThreeTile,
|
|
14
|
+
WinterDomTile,
|
|
15
|
+
} from "./components";
|
|
16
|
+
|
|
17
|
+
/** Explicitly registered implementations. No evaluation, URL imports or package downloads. */
|
|
18
|
+
export const examplePackages: readonly ImplementationPackage[] = [
|
|
19
|
+
{
|
|
20
|
+
name: "@moloodev/layout-examples",
|
|
21
|
+
version: "1.0.0",
|
|
22
|
+
abi: 1,
|
|
23
|
+
target: "react-dom",
|
|
24
|
+
entries: {
|
|
25
|
+
Tile: { contract: tileContract, component: DomTile },
|
|
26
|
+
WinterTile: { contract: tileContract, component: WinterDomTile },
|
|
27
|
+
Stack: { contract: groupContract, component: DomStack },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "@moloodev/layout-examples",
|
|
32
|
+
version: "1.0.0",
|
|
33
|
+
abi: 1,
|
|
34
|
+
target: "react-three-fiber",
|
|
35
|
+
entries: {
|
|
36
|
+
Tile: { contract: tileContract, component: ThreeTile },
|
|
37
|
+
Group: { contract: groupContract, component: ThreeGroup },
|
|
38
|
+
Snowfall: { contract: groupContract, component: Snowfall },
|
|
39
|
+
Building: { contract: buildingContract, component: Building },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
];
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { composeSurfaces } from "./compose";
|
|
2
|
+
export type { ElementIdentity, ElementObserverProps } from "./compose";
|
|
3
|
+
export { prepareLayout } from "./layout";
|
|
4
|
+
export type { PreparedLayout } from "./layout";
|
|
5
|
+
export type { PreparedSurface, RuntimeComponentProps, NativeSlotRefs } from "./runtime";
|
|
6
|
+
export * from "./layout-format";
|
|
7
|
+
export * from "./catalog";
|
|
8
|
+
export {
|
|
9
|
+
catalogFormat,
|
|
10
|
+
readContent as readCatalogContent,
|
|
11
|
+
valueValidator,
|
|
12
|
+
type Content as CatalogContent,
|
|
13
|
+
type ComponentContract,
|
|
14
|
+
type ComponentDefinition,
|
|
15
|
+
type ResourceReference,
|
|
16
|
+
type ValueSchema,
|
|
17
|
+
type ObjectSchema,
|
|
18
|
+
} from "./catalog-format";
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isArray,
|
|
3
|
+
isMap,
|
|
4
|
+
rootMap,
|
|
5
|
+
stopCapturing,
|
|
6
|
+
toShared,
|
|
7
|
+
transact,
|
|
8
|
+
type Doc,
|
|
9
|
+
type SharedMap,
|
|
10
|
+
} from "@moloodev/editor-sdk/crdt";
|
|
11
|
+
import { assertContent, blankContent } from "./contract";
|
|
12
|
+
import type { Content } from "./schema";
|
|
13
|
+
|
|
14
|
+
type RecordValue = Record<string, unknown>;
|
|
15
|
+
const record = (value: unknown): value is RecordValue =>
|
|
16
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
17
|
+
const equal = (a: unknown, b: unknown) =>
|
|
18
|
+
JSON.stringify(a) === JSON.stringify(b);
|
|
19
|
+
|
|
20
|
+
function patch(map: SharedMap, before: RecordValue, after: RecordValue): void {
|
|
21
|
+
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
22
|
+
const previous = before[key];
|
|
23
|
+
const next = after[key];
|
|
24
|
+
if (equal(previous, next)) continue;
|
|
25
|
+
const shared = map.get(key);
|
|
26
|
+
if (!Object.hasOwn(after, key)) map.delete(key);
|
|
27
|
+
else if (record(previous) && record(next) && isMap(shared))
|
|
28
|
+
patch(shared, previous, next);
|
|
29
|
+
else if (
|
|
30
|
+
Array.isArray(previous) &&
|
|
31
|
+
Array.isArray(next) &&
|
|
32
|
+
isArray(shared)
|
|
33
|
+
) {
|
|
34
|
+
// Preserve the common prefix/suffix and their Yjs identities, including remote insertions.
|
|
35
|
+
let start = 0;
|
|
36
|
+
while (
|
|
37
|
+
start < previous.length &&
|
|
38
|
+
start < next.length &&
|
|
39
|
+
equal(previous[start], next[start])
|
|
40
|
+
)
|
|
41
|
+
start++;
|
|
42
|
+
let end = 0;
|
|
43
|
+
while (
|
|
44
|
+
end < previous.length - start &&
|
|
45
|
+
end < next.length - start &&
|
|
46
|
+
equal(previous[previous.length - 1 - end], next[next.length - 1 - end])
|
|
47
|
+
)
|
|
48
|
+
end++;
|
|
49
|
+
const removed = previous.length - start - end;
|
|
50
|
+
if (removed) shared.delete(start, removed);
|
|
51
|
+
const inserted = next.slice(start, next.length - end);
|
|
52
|
+
if (inserted.length) shared.insert(start, inserted.map(toShared));
|
|
53
|
+
} else map.set(key, toShared(next));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function projectLayout(doc: Doc): Content {
|
|
58
|
+
const raw = rootMap(doc).toJSON();
|
|
59
|
+
if (!Object.keys(raw).length) return blankContent();
|
|
60
|
+
assertContent(raw);
|
|
61
|
+
return raw;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One synchronous draft callback = one shared undo step. Validation precedes every write.
|
|
65
|
+
* The host owns createUndo; no additional document, observer, or undo manager is created here.
|
|
66
|
+
* Semantic validation is injected by ../catalog; format validation is unconditional.
|
|
67
|
+
*/
|
|
68
|
+
export function applyLayoutChange<T>(
|
|
69
|
+
doc: Doc,
|
|
70
|
+
change: (draft: Content) => T,
|
|
71
|
+
validate?: (content: Content) => void,
|
|
72
|
+
): T {
|
|
73
|
+
const rawBefore = rootMap(doc).toJSON();
|
|
74
|
+
const before = projectLayout(doc);
|
|
75
|
+
const draft = structuredClone(before);
|
|
76
|
+
const result = change(draft);
|
|
77
|
+
if (result && typeof result === "object" && "then" in result)
|
|
78
|
+
throw new Error("Layout changes must be synchronous");
|
|
79
|
+
assertContent(draft);
|
|
80
|
+
validate?.(draft);
|
|
81
|
+
if (!equal(rawBefore, rootMap(doc).toJSON()))
|
|
82
|
+
throw new Error("Layout changed during draft computation");
|
|
83
|
+
if (equal(before, draft)) return result;
|
|
84
|
+
stopCapturing(doc);
|
|
85
|
+
transact(doc, () => patch(rootMap(doc), rootMap(doc).toJSON(), draft));
|
|
86
|
+
stopCapturing(doc);
|
|
87
|
+
return result;
|
|
88
|
+
}
|