fix(studio): render complete interactive gallery previews
This commit is contained in:
1 parent
c765d76a06
commit
9520651330
26 files changed
+5761
-39
No files matched your search
@@ -0,0 +1,160 @@
|
||||
import samples from "./gallery-samples.json";
|
||||
import { resolveControlTemplate } from "@wrnexus/core";
|
||||
|
||||
const illustration = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360"><rect width="640" height="360" fill="#dbeafe"/><path d="M0 260L190 100l130 140 120-100 200 160v60H0Z" fill="#2563eb"/><circle cx="470" cy="80" r="36" fill="#f59e0b"/></svg>')}`;
|
||||
const map = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360"><rect width="640" height="360" fill="#e2e8d5"/><path d="M0 140h640M200 0v360M440 0v360" stroke="#fff" stroke-width="32"/><path d="M0 140h640M200 0v360M440 0v360" stroke="#b7c2ca" stroke-width="2"/><rect x="260" y="210" width="120" height="100" rx="20" fill="#a4c7a1"/></svg>')}`;
|
||||
|
||||
function localAssets(value: unknown, key = ""): unknown {
|
||||
if (typeof value === "string")
|
||||
return /^(?:https?:\/\/|\/showcase-images\/)/.test(value) &&
|
||||
/src|image|avatar|poster|logo/i.test(key)
|
||||
? illustration
|
||||
: /^https?:\/\//.test(value) && /href|action/i.test(key)
|
||||
? "#sample"
|
||||
: value;
|
||||
if (Array.isArray(value)) return value.map((entry) => localAssets(entry, key));
|
||||
if (value && typeof value === "object")
|
||||
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, localAssets(v, k)]));
|
||||
return value;
|
||||
}
|
||||
/** Fixed, populated sample content; never contains draft-supplied executable markup. */
|
||||
export function gallerySample(name: string) {
|
||||
const source = (samples as Record<string, { props: Record<string, unknown>; content: string }>)[
|
||||
name
|
||||
];
|
||||
if (!source) throw Error(`Missing gallery sample: ${name}`);
|
||||
const props = localAssets(structuredClone(source.props)) as Record<string, unknown>;
|
||||
const field = [
|
||||
"Input",
|
||||
"Textarea",
|
||||
"Select",
|
||||
"Checkbox",
|
||||
"Radio",
|
||||
"Switch",
|
||||
"AdvancedSelect",
|
||||
"ComboBox",
|
||||
"DatePicker",
|
||||
"TimePicker",
|
||||
"RangeSlider",
|
||||
"InputNumber",
|
||||
"PinInput",
|
||||
"FileInput",
|
||||
"StrongPassword",
|
||||
"TogglePassword",
|
||||
].includes(name);
|
||||
if (Object.hasOwn(props, "id") || field) props.id = `sample-${name.toLowerCase()}`;
|
||||
if (field && !props.name) props.name = `sample-${name.toLowerCase()}`;
|
||||
props.class = "";
|
||||
let content = source.content;
|
||||
let hint = "Sample data. Interact with the controls to try this component.";
|
||||
if (name === "Map") {
|
||||
Object.assign(props, {
|
||||
title: "Sample location map",
|
||||
description: "Schematic sample: select a marker or use the zoom controls.",
|
||||
imageSrc: map,
|
||||
imageAlt: "Schematic sample map",
|
||||
items: [
|
||||
{ label: "Office", x: 32, y: 42 },
|
||||
{ label: "Park", x: 68, y: 70 },
|
||||
],
|
||||
});
|
||||
hint = "Local schematic sample; no map service or API key is needed.";
|
||||
}
|
||||
if (name === "DrawflowCanvas" || name === "FlowCanvas")
|
||||
Object.assign(props, {
|
||||
nodes: [
|
||||
{
|
||||
id: "start",
|
||||
label: "Start",
|
||||
x: 30,
|
||||
y: 50,
|
||||
input: false,
|
||||
ports: [{ key: "next", label: "Next", target: "review" }],
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
label: "Review",
|
||||
x: 260,
|
||||
y: 100,
|
||||
ports: [],
|
||||
},
|
||||
],
|
||||
palette: [{ kind: "task", label: "Add task" }],
|
||||
});
|
||||
if (name === "Toaster")
|
||||
content = '<button type="button" data-preview-toast>Show sample notification</button>';
|
||||
if (name === "BackToTop") {
|
||||
props.threshold = 0;
|
||||
props.alwaysVisible = true;
|
||||
props.showLabel = true;
|
||||
hint = "Scroll this preview, then use the back-to-top control.";
|
||||
}
|
||||
if (name === "DeviceFrame") {
|
||||
props.items = [];
|
||||
content =
|
||||
"<article><h2>Sample mobile page</h2><p>This local content sits inside the device frame.</p><h3>Account preferences</h3><p>Rotate the preview to compare portrait and landscape layouts.</p></article>";
|
||||
}
|
||||
if (name === "VisuallyHidden")
|
||||
hint = "This accessibility component intentionally hides its content visually.";
|
||||
if (name === "CustomScrollbar")
|
||||
content = Array.from(
|
||||
{ length: 12 },
|
||||
(_, i) => `<p>Sample row ${i + 1} — scroll to see more content.</p>`,
|
||||
).join("");
|
||||
if (["AdvancedSelect", "Checkbox", "Select"].includes(name)) delete props.values;
|
||||
if (["AuthForm", "AuthSplitLayout", "FileInput"].includes(name))
|
||||
hint = "Preview only. Submissions and file uploads are not sent anywhere.";
|
||||
return { props, content, hint };
|
||||
}
|
||||
|
||||
export function previewSlots(html: string, content: string) {
|
||||
const named = new Map<string, string>();
|
||||
// Author-controlled sample slots are the only HTML accepted here.
|
||||
let defaultContent = "";
|
||||
let cursor = 0;
|
||||
const slots = /<div data-slot="([\w-]+)">/g;
|
||||
for (let match = slots.exec(content); match; match = slots.exec(content)) {
|
||||
defaultContent += content.slice(cursor, match.index);
|
||||
const tags = /<\/?div\b[^>]*>/g;
|
||||
tags.lastIndex = slots.lastIndex;
|
||||
let depth = 1;
|
||||
let end = tags.lastIndex;
|
||||
for (let tag = tags.exec(content); tag; tag = tags.exec(content)) {
|
||||
depth += tag[0].startsWith("</") ? -1 : 1;
|
||||
if (depth === 0) {
|
||||
named.set(match[1]!, content.slice(slots.lastIndex, tag.index));
|
||||
end = tags.lastIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (depth !== 0) throw Error("Unbalanced sample slot markup.");
|
||||
cursor = end;
|
||||
slots.lastIndex = end;
|
||||
}
|
||||
defaultContent += content.slice(cursor);
|
||||
return html.replace(/<slot\b([^>]*)>([\s\S]*?)<\/slot>/g, (all, attributes) => {
|
||||
const name = /\bname=["']([^"']+)["']/.exec(attributes)?.[1];
|
||||
const replacement = name ? named.get(name) : defaultContent;
|
||||
return replacement ? `<slot${attributes}>${replacement}</slot>` : all;
|
||||
});
|
||||
}
|
||||
|
||||
/** Conditional/loop bodies are request-owned templates, and need the same slots as SSR. */
|
||||
export function previewSampleMarkup(html: string, content: string) {
|
||||
const seen = new Set<string>();
|
||||
const visit = (markup: string): string => {
|
||||
for (const match of markup.matchAll(/data-wrn-(?:if|each)="(@[^"]+)"/g)) {
|
||||
if (seen.has(match[1]!)) continue;
|
||||
seen.add(match[1]!);
|
||||
const definition = resolveControlTemplate(match[1]!);
|
||||
for (const entry of Array.isArray(definition) ? definition : [definition]) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
for (const key of ["body", "empty"]) {
|
||||
if (typeof entry[key] === "string") entry[key] = visit(entry[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return previewSlots(markup, content);
|
||||
};
|
||||
return visit(html);
|
||||
}
|
||||
Reference in new issue
Block a user