pagiera 0.2.0-alpha.44 → 0.2.0-alpha.45
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/dist/data.js.map +1 -1
- package/dist/full-editor.js +75 -10
- package/dist/full-editor.js.map +2 -2
- package/dist/internal/app/api/ai-design/route.d.ts +5 -0
- package/dist/internal/app/api/ai-design/route.d.ts.map +1 -1
- package/dist/internal/app/api/ai-design/route.js +1 -1
- package/dist/internal/app/api/ai-design/route.js.map +1 -1
- package/dist/internal/app/p/editor/editor.d.ts.map +1 -1
- package/dist/internal/app/p/editor/editor.js +23 -7
- package/dist/internal/app/p/editor/editor.js.map +1 -1
- package/dist/internal/app/p/editor/fields.d.ts +1 -1
- package/dist/internal/app/p/editor/fields.d.ts.map +1 -1
- package/dist/internal/app/p/editor/fields.js +5 -1
- package/dist/internal/app/p/editor/fields.js.map +1 -1
- package/dist/internal/app/p/editor/inspector.d.ts.map +1 -1
- package/dist/internal/app/p/editor/inspector.js +9 -2
- package/dist/internal/app/p/editor/inspector.js.map +1 -1
- package/dist/internal/lib/editor/style.d.ts.map +1 -1
- package/dist/internal/lib/editor/style.js +8 -0
- package/dist/internal/lib/editor/style.js.map +1 -1
- package/dist/internal/lib/editor/types.d.ts +3 -1
- package/dist/internal/lib/editor/types.d.ts.map +1 -1
- package/dist/internal/lib/editor/types.js.map +1 -1
- package/dist/internal/lib/editor/validate.d.ts.map +1 -1
- package/dist/internal/lib/editor/validate.js +2 -1
- package/dist/internal/lib/editor/validate.js.map +1 -1
- package/dist/internal/lib/pages.d.ts +1 -0
- package/dist/internal/lib/pages.d.ts.map +1 -1
- package/dist/internal/lib/render/css.d.ts.map +1 -1
- package/dist/internal/lib/render/css.js +10 -2
- package/dist/internal/lib/render/css.js.map +1 -1
- package/dist/runtime.js +13 -2
- package/dist/runtime.js.map +2 -2
- package/dist/server.js +3 -2
- package/dist/server.js.map +2 -2
- package/package.json +1 -1
package/dist/data.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/internal/lib/editor/types.ts", "../src/internal/lib/data/source.ts", "../src/internal/lib/render/load-data.ts"],
|
|
4
|
-
"sourcesContent": ["export const ELEMENT_TYPES = [\n \"Frame\",\n \"Stack\",\n \"Section\",\n \"Container\",\n \"Grid\",\n \"Heading\",\n \"Text\",\n \"Image\",\n \"Button\",\n \"Video\",\n \"Icon\",\n \"Form\",\n \"Input\",\n \"Textarea\",\n \"Request\",\n \"Repeat\",\n] as const;\n\nexport type ElementType = (typeof ELEMENT_TYPES)[number];\n\n/** One key/value pair on a request; values may carry `{{\u2026}}` tokens. */\nexport type RequestPair = { key: string; value: string };\n\nexport const HTTP_METHODS = [\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\"] as const;\nexport type HttpMethod = (typeof HTTP_METHODS)[number];\n\nexport const DATA_SOURCE_NOT_FOUND_BEHAVIORS = [\"empty\", \"page-404\"] as const;\nexport type DataSourceNotFoundBehavior = (typeof DATA_SOURCE_NOT_FOUND_BEHAVIORS)[number];\n\n/** Methods that carry a request body. */\nexport function sendsBody(method: HttpMethod) {\n return method === \"POST\" || method === \"PUT\" || method === \"PATCH\";\n}\n\n/**\n * A JSON endpoint the page pulls content from. Sources live on the page so a\n * Repeat block can name one without carrying the URL on every element.\n */\nexport type DataSource = {\n id: string;\n name: string;\n url: string;\n /** Dotted path to the array inside the payload; \"\" when it is the root. */\n path: string;\n method?: HttpMethod;\n /** JSON body for POST/PUT/PATCH; tokens are resolved before sending. */\n body?: string;\n /** Appended to the URL as a query string. */\n params?: RequestPair[];\n /** Sent as request headers \u2014 for API keys and the like. */\n headers?: RequestPair[];\n /** Controls whether an upstream 404 empties this source or rejects the whole page. */\n onNotFound?: DataSourceNotFoundBehavior;\n};\n\n/**\n * Values a request token can read. Everything else resolves to an empty\n * string, so a typo cannot leak an unrelated value into the URL.\n */\nexport type RequestContext = {\n /** The visitor's query string, e.g. `{{query.id}}` on /post?id=5. */\n query: Record<string, string>;\n /** Values captured by a dynamic page path, e.g. `{{params.slug}}`. */\n params: Record<string, string>;\n /** The page being rendered, e.g. `{{page.slug}}`. */\n page: { slug: string };\n};\n\nexport type ResizeHandle = \"nw\" | \"ne\" | \"sw\" | \"se\" | \"n\" | \"s\" | \"w\" | \"e\";\n\n/* ------------------------------------------------------------- breakpoints */\n\nexport const BREAKPOINTS = [\"desktop\", \"tablet\", \"mobile\"] as const;\nexport type Breakpoint = string;\n\nexport type BreakpointDefinition = {\n id: string;\n name: string;\n width: number;\n};\n\nexport const DEFAULT_BREAKPOINTS: BreakpointDefinition[] = [\n { id: \"desktop\", name: \"Desktop\", width: 1280 },\n { id: \"tablet\", name: \"Tablet\", width: 768 },\n { id: \"mobile\", name: \"Mobile\", width: 375 },\n];\n\nexport const BREAKPOINT_WIDTHS: Record<string, number> = {\n desktop: 1280,\n tablet: 768,\n mobile: 375,\n};\n\n/**\n * Styles cascade from the widest breakpoint down, so a value set on desktop\n * holds everywhere until a narrower breakpoint overrides it.\n */\nexport const BREAKPOINT_CHAIN: Record<string, Breakpoint[]> = {\n desktop: [\"desktop\"],\n tablet: [\"desktop\", \"tablet\"],\n mobile: [\"desktop\", \"tablet\", \"mobile\"],\n};\n\n/* ------------------------------------------------------------------ styles */\n\n/** `fill` stretches to the parent, `auto` shrinks to the content. */\nexport type SizeMode = \"fixed\" | \"fill\" | \"auto\";\nexport type Constraint = \"start\" | \"center\" | \"end\" | \"stretch\";\n/** `absolute` positions children by x/y; `stack` lays them out with flexbox. */\nexport type LayoutMode = \"absolute\" | \"stack\";\nexport type Direction = \"row\" | \"column\";\nexport type Justify = \"start\" | \"center\" | \"end\" | \"between\";\nexport type Align = \"start\" | \"center\" | \"end\" | \"stretch\";\nexport type TextAlign = \"left\" | \"center\" | \"right\" | \"justify\";\nexport type TextTransform = \"none\" | \"uppercase\" | \"lowercase\" | \"capitalize\";\nexport type ObjectFit = \"cover\" | \"contain\" | \"fill\" | \"none\";\nexport type Overflow = \"visible\" | \"hidden\" | \"auto\" | \"scroll\";\nexport type Entrance = \"none\" | \"fade\" | \"up\" | \"down\" | \"left\" | \"right\" | \"zoom\";\nexport type MotionCurve = \"ease\" | \"spring\";\nexport type CursorStyle = \"auto\" | \"default\" | \"pointer\" | \"text\" | \"grab\" | \"zoom-in\" | \"none\";\n/**\n * How an element sits relative to its siblings.\n *\n * `absolute` is per element, not per container: it lifts this one out of the\n * flow and places it at x/y while everything around it keeps stacking. Making\n * the whole parent free instead would move every sibling to satisfy one of\n * them.\n */\nexport type PositionMode = \"static\" | \"sticky\" | \"fixed\" | \"absolute\";\n/** Which edge a pinned element holds to. */\nexport type PinSide = \"top\" | \"bottom\" | \"left\" | \"right\";\nexport type BgSize = \"cover\" | \"contain\" | \"auto\";\nexport type BlendMode =\n | \"normal\"\n | \"multiply\"\n | \"screen\"\n | \"overlay\"\n | \"darken\"\n | \"lighten\"\n | \"difference\"\n | \"luminosity\";\nexport type BorderStyle = \"solid\" | \"dashed\" | \"dotted\";\n\nexport type ElementStyle = {\n // Box \u2014 x/y only apply inside an `absolute` parent.\n x: number;\n y: number;\n constraintX: Constraint;\n constraintY: Constraint;\n w: number;\n h: number;\n widthMode: SizeMode;\n heightMode: SizeMode;\n\n // How this element arranges its own children.\n layout: LayoutMode;\n direction: Direction;\n gap: number;\n padT: number;\n padR: number;\n padB: number;\n padL: number;\n /**\n * Space held below the element, outside its own box.\n *\n * Separate from `padB` on purpose: padding is the breathing room the\n * author gave the content inside a section, while this is the distance to\n * whatever comes next. Sharing one value would make adjusting the rhythm\n * between sections quietly reflow their insides.\n */\n marginB: number;\n justify: Justify;\n align: Align;\n wrap: boolean;\n /** Grid columns; only read when `layout` is `stack` on a Grid element. */\n columns: number;\n\n // Appearance\n bg: string;\n /** A full CSS gradient value, or \"\" for none. Painted over `bg`. */\n gradient: string;\n color: string;\n radius: number;\n opacity: number;\n borderW: number;\n /** Per-edge override; null inherits borderW. */\n borderT: number | null;\n borderR: number | null;\n borderB: number | null;\n borderL: number | null;\n borderC: string;\n borderStyle: BorderStyle;\n /** A full CSS box-shadow value, or \"\" for none. */\n shadow: string;\n rotate: number;\n\n // Typography\n fontFamily: string;\n fontSize: number;\n fontWeight: string;\n lineHeight: number;\n letterSpacing: number;\n textAlign: TextAlign;\n textTransform: TextTransform;\n\n // Composition \u2014 the pieces that make a layout feel designed rather than\n // stacked: clipping, sticky rails, imagery, glass and blend effects.\n overflow: Overflow;\n position: PositionMode;\n /** CSS stacking order, independent from the internal document order. */\n zIndex: number;\n /** Distance from `pinSide` while pinned; read when position is sticky or fixed. */\n stickyOffset: number;\n /** Edge a sticky or fixed element pins to. */\n pinSide: PinSide;\n /** Background image URL, or \"\" for none. Painted over `gradient`. */\n bgImage: string;\n bgSize: BgSize;\n bgPosition: string;\n /** Blurs the element's own content, in px. */\n blur: number;\n /** Blurs whatever sits behind the element, in px \u2014 the glass effect. */\n backdropBlur: number;\n blendMode: BlendMode;\n /** Percent; 100 leaves the element alone. */\n scale: number;\n /** A CSS ratio such as \"16/9\", or \"\" to leave height to the layout. */\n aspectRatio: string;\n\n /** Entrance effect, played once when the element scrolls into view. */\n entrance: Entrance;\n /** Milliseconds. */\n entranceDuration: number;\n entranceDelay: number;\n entranceCurve: MotionCurve;\n entranceBezier: string;\n springStiffness: number;\n springDamping: number;\n cursor: CursorStyle;\n\n /** Hidden at this breakpoint. */\n hidden: boolean;\n};\n\nexport const STYLE_KEYS = [\n \"x\",\n \"y\",\n \"constraintX\",\n \"constraintY\",\n \"w\",\n \"h\",\n \"widthMode\",\n \"heightMode\",\n \"layout\",\n \"direction\",\n \"gap\",\n \"padT\",\n \"padR\",\n \"padB\",\n \"padL\",\n \"marginB\",\n \"justify\",\n \"align\",\n \"wrap\",\n \"columns\",\n \"bg\",\n \"gradient\",\n \"color\",\n \"radius\",\n \"opacity\",\n \"borderW\",\n \"borderT\",\n \"borderR\",\n \"borderB\",\n \"borderL\",\n \"borderC\",\n \"borderStyle\",\n \"shadow\",\n \"rotate\",\n \"fontFamily\",\n \"fontSize\",\n \"fontWeight\",\n \"lineHeight\",\n \"letterSpacing\",\n \"textAlign\",\n \"textTransform\",\n \"overflow\",\n \"position\",\n \"zIndex\",\n \"stickyOffset\",\n \"pinSide\",\n \"bgImage\",\n \"bgSize\",\n \"bgPosition\",\n \"blur\",\n \"backdropBlur\",\n \"blendMode\",\n \"scale\",\n \"aspectRatio\",\n \"entrance\",\n \"entranceDuration\",\n \"entranceDelay\",\n \"entranceCurve\",\n \"entranceBezier\",\n \"springStiffness\",\n \"springDamping\",\n \"cursor\",\n \"hidden\",\n] as const satisfies ReadonlyArray<keyof ElementStyle>;\n\nexport type StyleKey = (typeof STYLE_KEYS)[number];\n\nexport type CanvasElement = {\n id: string;\n type: ElementType;\n name?: string;\n parentId?: string;\n z: number;\n locked?: boolean;\n /** Page-local reusable component metadata. */\n componentRole?: \"master\" | \"instance\";\n componentId?: string;\n componentSourceId?: string;\n variant?: string;\n styleBindings?: Partial<Record<StyleKey, string>>;\n\n // Content is shared across breakpoints.\n content?: string;\n /** Sandboxed HTML/CSS used by code components. */\n code?: string;\n src?: string;\n alt?: string;\n objectFit?: ObjectFit;\n iconName?: PagieraIconName;\n placeholder?: string;\n fieldName?: string;\n inputType?: \"text\" | \"email\" | \"password\" | \"number\" | \"tel\" | \"url\" | \"search\";\n required?: boolean;\n formAction?: string;\n formMethod?: HttpMethod;\n formSubmitMode?: \"request\" | \"native\";\n formContentType?: \"json\" | \"form-data\" | \"urlencoded\";\n /** Optional request body. `{{form.email}}` tokens read submitted fields. */\n formBody?: string;\n /** One `Header: value` pair per line. */\n formHeaders?: string;\n formSuccessMessage?: string;\n formErrorMessage?: string;\n formResetOnSuccess?: boolean;\n buttonType?: \"button\" | \"submit\" | \"reset\";\n href?: string;\n target?: \"_self\" | \"_blank\";\n interaction?: {\n trigger: \"click\";\n action: \"navigate\" | \"scroll-to\" | \"toggle-layer\" | \"show-layer\" | \"hide-layer\";\n value: string;\n target?: \"_self\" | \"_blank\";\n };\n\n /** Data source read by a Request/Repeat block or a directly-bound element. */\n sourceId?: string;\n /**\n * Inside Request/Repeat, pulls this field off the current object instead\n * of using the element's content. Dotted paths work: \"author.name\".\n */\n binding?: string;\n\n /** Desktop values; every breakpoint falls back to these. */\n base: ElementStyle;\n /** Narrower-breakpoint deltas, applied over `base` in cascade order. */\n overrides?: Record<string, Partial<ElementStyle>>;\n /** Applied on pointer hover, on top of the resolved breakpoint style. */\n hover?: Partial<ElementStyle>;\n /** Applied while the pointer is pressed. */\n press?: Partial<ElementStyle>;\n loop?: { type: \"pulse\" | \"float\" | \"spin\"; duration: number };\n draggable?: boolean;\n};\n\n/* ---------------------------------------------------------------- defaults */\n\nexport const BASE_STYLE: ElementStyle = {\n x: 0,\n y: 0,\n constraintX: \"start\",\n constraintY: \"start\",\n w: 200,\n h: 100,\n widthMode: \"fixed\",\n heightMode: \"fixed\",\n\n layout: \"absolute\",\n direction: \"column\",\n gap: 0,\n padT: 0,\n padR: 0,\n padB: 0,\n padL: 0,\n marginB: 0,\n justify: \"start\",\n align: \"start\",\n wrap: false,\n columns: 3,\n\n bg: \"transparent\",\n gradient: \"\",\n color: \"#27272a\",\n radius: 0,\n opacity: 100,\n borderW: 0,\n borderT: null,\n borderR: null,\n borderB: null,\n borderL: null,\n borderC: \"transparent\",\n borderStyle: \"solid\",\n shadow: \"\",\n rotate: 0,\n\n fontFamily: \"inherit\",\n fontSize: 16,\n fontWeight: \"normal\",\n lineHeight: 1.5,\n letterSpacing: 0,\n textAlign: \"left\",\n textTransform: \"none\",\n\n overflow: \"visible\",\n position: \"static\",\n zIndex: 0,\n stickyOffset: 0,\n pinSide: \"top\",\n bgImage: \"\",\n bgSize: \"cover\",\n bgPosition: \"center\",\n blur: 0,\n backdropBlur: 0,\n blendMode: \"normal\",\n scale: 100,\n aspectRatio: \"\",\n\n entrance: \"none\",\n entranceDuration: 600,\n entranceDelay: 0,\n entranceCurve: \"ease\",\n entranceBezier: \"0.44, 0, 0.56, 1\",\n springStiffness: 300,\n springDamping: 30,\n cursor: \"auto\",\n\n hidden: false,\n};\n\nexport function makeStyle(overrides: Partial<ElementStyle>): ElementStyle {\n return { ...BASE_STYLE, ...overrides };\n}\n\n/** Every field a freshly dropped element starts with. */\nexport const ELEMENT_DEFAULTS: Record<\n ElementType,\n { style: Partial<ElementStyle>; props?: Partial<CanvasElement> }\n> = {\n Frame: {\n style: {\n w: 640,\n h: 420,\n widthMode: \"fixed\",\n heightMode: \"fixed\",\n layout: \"absolute\",\n direction: \"column\",\n overflow: \"hidden\",\n bg: \"#ffffff\",\n borderW: 1,\n borderC: \"#e4e4e7\",\n radius: 12,\n },\n },\n Stack: {\n style: {\n w: 600,\n h: 160,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"column\",\n gap: 16,\n padT: 0,\n padR: 0,\n padB: 0,\n padL: 0,\n justify: \"start\",\n align: \"stretch\",\n bg: \"transparent\",\n },\n },\n Section: {\n style: {\n w: 1280,\n h: 480,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"column\",\n gap: 24,\n padT: 64,\n padR: 48,\n padB: 64,\n padL: 48,\n justify: \"start\",\n align: \"stretch\",\n bg: \"#ffffff\",\n },\n },\n Container: {\n style: {\n w: 600,\n h: 240,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"column\",\n gap: 16,\n padT: 24,\n padR: 24,\n padB: 24,\n padL: 24,\n align: \"stretch\",\n bg: \"transparent\",\n borderW: 1,\n borderC: \"#e4e4e7\",\n radius: 12,\n },\n },\n Grid: {\n style: {\n w: 900,\n h: 300,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"row\",\n columns: 3,\n gap: 24,\n align: \"stretch\",\n },\n },\n Heading: {\n style: {\n w: 480,\n h: 48,\n widthMode: \"fill\",\n heightMode: \"auto\",\n fontSize: 40,\n fontWeight: \"bold\",\n lineHeight: 1.2,\n letterSpacing: -0.5,\n color: \"#18181b\",\n },\n props: { content: \"Heading\" },\n },\n Text: {\n style: {\n w: 480,\n h: 24,\n widthMode: \"fill\",\n heightMode: \"auto\",\n fontSize: 16,\n lineHeight: 1.6,\n color: \"#52525b\",\n },\n props: { content: \"Text block content\" },\n },\n Image: {\n style: { w: 400, h: 260, widthMode: \"fill\", bg: \"#e4e4e7\", radius: 12 },\n props: { src: \"\", alt: \"\", objectFit: \"cover\" },\n },\n Button: {\n style: {\n w: 140,\n h: 44,\n widthMode: \"auto\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"row\",\n justify: \"center\",\n align: \"center\",\n padT: 12,\n padR: 22,\n padB: 12,\n padL: 22,\n bg: \"#2563eb\",\n color: \"#ffffff\",\n radius: 8,\n fontSize: 15,\n fontWeight: \"500\",\n textAlign: \"center\",\n },\n props: { content: \"Button\", href: \"\", target: \"_self\", buttonType: \"button\" },\n },\n Video: {\n style: { w: 640, h: 360, widthMode: \"fill\", bg: \"#18181b\", radius: 12 },\n props: { src: \"\" },\n },\n Icon: {\n style: { w: 24, h: 24, widthMode: \"fixed\", heightMode: \"fixed\", color: \"#5402E6\" },\n props: { iconName: \"star\" },\n },\n Form: {\n style: {\n w: 560,\n h: 240,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"column\",\n gap: 14,\n align: \"stretch\",\n },\n props: {\n formAction: \"\",\n formMethod: \"POST\",\n formSubmitMode: \"request\",\n formContentType: \"json\",\n formSuccessMessage: \"Sent successfully.\",\n formErrorMessage: \"Something went wrong.\",\n },\n },\n Input: {\n style: {\n w: 320,\n h: 46,\n widthMode: \"fill\",\n heightMode: \"fixed\",\n padT: 0,\n padR: 14,\n padB: 0,\n padL: 14,\n bg: \"#ffffff\",\n color: \"#18181b\",\n borderW: 1,\n borderC: \"#d4d4d8\",\n radius: 10,\n fontSize: 15,\n },\n props: { placeholder: \"Enter a value\u2026\", fieldName: \"field\", inputType: \"text\" },\n },\n Textarea: {\n style: {\n w: 320,\n h: 120,\n widthMode: \"fill\",\n heightMode: \"fixed\",\n padT: 12,\n padR: 14,\n padB: 12,\n padL: 14,\n bg: \"#ffffff\",\n color: \"#18181b\",\n borderW: 1,\n borderC: \"#d4d4d8\",\n radius: 10,\n fontSize: 15,\n },\n props: { placeholder: \"Write your message\u2026\", fieldName: \"message\" },\n },\n Request: {\n style: {\n w: 900,\n h: 200,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"column\",\n gap: 16,\n align: \"stretch\",\n },\n props: { sourceId: \"\" },\n },\n Repeat: {\n style: {\n w: 900,\n h: 200,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"row\",\n gap: 24,\n align: \"stretch\",\n wrap: true,\n },\n props: { sourceId: \"\" },\n },\n};\n\n/* ----------------------------------------------------------------- helpers */\n\nconst CONTAINER_TYPES: ReadonlySet<ElementType> = new Set([\n \"Frame\",\n \"Stack\",\n \"Section\",\n \"Container\",\n \"Grid\",\n \"Button\",\n \"Form\",\n \"Request\",\n \"Repeat\",\n]);\n\n/** Whether an element can hold children. */\nexport function isContainer(type: ElementType) {\n return CONTAINER_TYPES.has(type);\n}\n\nconst TEXTUAL_TYPES: ReadonlySet<ElementType> = new Set([\n \"Heading\",\n \"Text\",\n \"Button\",\n]);\n\n/** Whether an element renders editable text. */\nexport function isTextual(type: ElementType) {\n return TEXTUAL_TYPES.has(type);\n}\n\n/** Page-level settings; the canvas behaves as the root container. */\nexport type RootStyle = {\n documentMode: \"page\" | \"component\";\n /** Content is centred inside this width unless `fullWidth` is set. */\n maxWidth: number;\n /** Editable design-surface height; the public page can still grow with content. */\n canvasHeight: number;\n /** Let content run edge to edge instead of being capped at `maxWidth`. */\n fullWidth: boolean;\n bg: string;\n layout: LayoutMode;\n direction: Direction;\n gap: number;\n padT: number;\n padR: number;\n padB: number;\n padL: number;\n align: Align;\n fontFamily: string;\n /** Cross-document animation used by links on the published site. */\n pageTransition: \"smooth\" | \"fade\" | \"slide\" | \"none\";\n /** Duration of the incoming published-page animation in milliseconds. */\n pageTransitionDuration: number;\n /** User-defined viewport previews, ordered from widest to narrowest. */\n breakpoints?: BreakpointDefinition[];\n /** The breakpoint whose values live in every element's `base` style. */\n baseBreakpointId?: string;\n variables?: DesignVariable[];\n customFonts?: CustomFont[];\n /**\n * Raw CSS appended after the generated sheet, so it can override any rule\n * the builder produced. Escape hatch for what the inspector cannot express.\n */\n customCss?: string;\n /**\n * Raw JavaScript run on the published page, after the document parses.\n *\n * It is deliberately never executed inside the editor: the canvas and the\n * template preview both render without scripting, so a page cannot reach\n * the editor it is being built in.\n */\n customJs?: string;\n};\n\nexport type CustomFont = { id: string; name: string; url: string; weight: number; style: \"normal\" | \"italic\" };\n\nexport type DesignVariable = {\n id: string;\n name: string;\n type: \"color\" | \"number\";\n value: string | number;\n};\n\nexport const DEFAULT_ROOT_STYLE: RootStyle = {\n documentMode: \"page\",\n maxWidth: 1280,\n canvasHeight: 800,\n fullWidth: false,\n bg: \"#ffffff\",\n layout: \"stack\",\n direction: \"column\",\n gap: 0,\n padT: 0,\n padR: 0,\n padB: 0,\n padL: 0,\n align: \"stretch\",\n fontFamily: \"inherit\",\n pageTransition: \"smooth\",\n pageTransitionDuration: 380,\n};\n\nexport const FONT_STACKS: Array<{ label: string; value: string }> = [\n { label: \"Inherit\", value: \"inherit\" },\n { label: \"Sans\", value: \"ui-sans-serif, system-ui, sans-serif\" },\n { label: \"Serif\", value: \"ui-serif, Georgia, serif\" },\n { label: \"Mono\", value: \"ui-monospace, SFMono-Regular, monospace\" },\n];\n\nexport const SHADOW_PRESETS: Array<{ label: string; value: string }> = [\n { label: \"None\", value: \"\" },\n { label: \"Small\", value: \"0 1px 2px rgba(0,0,0,0.06)\" },\n { label: \"Medium\", value: \"0 4px 12px rgba(0,0,0,0.08)\" },\n { label: \"Large\", value: \"0 12px 32px rgba(0,0,0,0.12)\" },\n { label: \"Glow\", value: \"0 0 0 4px rgba(37,99,235,0.15)\" },\n];\n\nexport const ASPECT_RATIOS: Array<{ label: string; value: string }> = [\n { label: \"Free\", value: \"\" },\n { label: \"Square 1:1\", value: \"1/1\" },\n { label: \"Photo 4:3\", value: \"4/3\" },\n { label: \"Wide 16:9\", value: \"16/9\" },\n { label: \"Ultra 21:9\", value: \"21/9\" },\n { label: \"Portrait 3:4\", value: \"3/4\" },\n];\n\nexport const ENTRANCES: Array<{ label: string; value: Entrance }> = [\n { label: \"None\", value: \"none\" },\n { label: \"Fade in\", value: \"fade\" },\n { label: \"Rise up\", value: \"up\" },\n { label: \"Drop down\", value: \"down\" },\n { label: \"Slide from left\", value: \"left\" },\n { label: \"Slide from right\", value: \"right\" },\n { label: \"Zoom in\", value: \"zoom\" },\n];\n\nexport const DRAG_MIME = \"application/pagiera-element-type\";\n/** Set when dragging an element that already exists on the canvas. */\nexport const MOVE_MIME = \"application/pagiera-element-id\";\nimport type { PagieraIconName } from \"../../../icon-names\";\n", "import {\n type DataSource,\n type HttpMethod,\n type RequestContext,\n type RequestPair,\n sendsBody,\n} from \"@/lib/editor/types\";\n\n/**\n * Fetching an author-supplied URL from our server is a server-side request\n * forgery vector: without checks it can reach cloud metadata endpoints, admin\n * services on localhost, or anything else inside the network perimeter. Every\n * hop is validated against this list, and redirects are followed by hand so a\n * public URL cannot bounce into private space.\n */\nconst BLOCKED_HOSTS = new Set([\n \"localhost\",\n \"127.0.0.1\",\n \"0.0.0.0\",\n \"::1\",\n \"[::1]\",\n \"metadata.google.internal\",\n \"metadata.goog\",\n]);\n\nconst PRIVATE_IPV4 =\n /^(10\\.|127\\.|0\\.|169\\.254\\.|192\\.168\\.|172\\.(1[6-9]|2\\d|3[01])\\.)/;\n\n/** Hostnames that only resolve inside a private network. */\nconst PRIVATE_SUFFIXES = [\".local\", \".internal\", \".localdomain\", \".home.arpa\"];\n\nexport const MAX_ROWS = 200;\nconst MAX_BYTES = 2_000_000;\nconst TIMEOUT_MS = 8000;\nconst MAX_REDIRECTS = 3;\n\nexport class DataSourceError extends Error {\n constructor(message: string, public readonly status?: number) {\n super(message);\n this.name = \"DataSourceError\";\n }\n}\n\nexport function assertFetchableUrl(raw: string): URL {\n let url: URL;\n try {\n url = new URL(raw);\n } catch {\n throw new DataSourceError(\"That is not a valid URL.\");\n }\n\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") {\n throw new DataSourceError(\"Only http and https URLs can be requested.\");\n }\n\n const host = url.hostname.toLowerCase();\n if (BLOCKED_HOSTS.has(host) || PRIVATE_IPV4.test(host)) {\n throw new DataSourceError(`Requests to ${host} are not allowed.`);\n }\n if (PRIVATE_SUFFIXES.some((suffix) => host.endsWith(suffix))) {\n throw new DataSourceError(`Requests to ${host} are not allowed.`);\n }\n // Bracketed IPv6 literals: anything link-local or unique-local.\n if (host.startsWith(\"[\") && /^\\[(fe80|fc|fd)/i.test(host)) {\n throw new DataSourceError(\"Requests to private addresses are not allowed.\");\n }\n\n return url;\n}\n\n/**\n * Walks a dotted path such as `data.items` or `results.0.tags`. An empty path\n * returns the payload itself, which is what a bare JSON array needs.\n */\nexport function selectPath(payload: unknown, path: string): unknown {\n const steps = path.split(\".\").map((s) => s.trim()).filter(Boolean);\n let cursor: unknown = payload;\n for (const step of steps) {\n if (cursor === null || typeof cursor !== \"object\") return undefined;\n cursor = (cursor as Record<string, unknown>)[step];\n }\n return cursor;\n}\n\n/** Reads a body with a hard cap so a huge response cannot exhaust memory. */\nasync function readCapped(response: Response): Promise<string> {\n const reader = response.body?.getReader();\n if (!reader) return \"\";\n\n const chunks: Uint8Array[] = [];\n let total = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n total += value.byteLength;\n if (total > MAX_BYTES) {\n await reader.cancel();\n throw new DataSourceError(\"That response is too large (over 2 MB).\");\n }\n chunks.push(value);\n }\n return new TextDecoder().decode(\n chunks.reduce<Uint8Array>((acc, chunk) => {\n const merged = new Uint8Array(acc.length + chunk.length);\n merged.set(acc);\n merged.set(chunk, acc.length);\n return merged;\n }, new Uint8Array()),\n );\n}\n\nexport type SourceResult = { rows: Array<Record<string, unknown>>; keys: string[] };\n\n/**\n * Requests a source and normalizes it to rows. A root object becomes one row\n * for Request; an array stays multiple rows for Repeat.\n */\nexport type LoadOptions = {\n /**\n * Seconds to cache the response for. `false` bypasses the cache \u2014 only for\n * the editor's Test button; on a rendered route it would force an\n * otherwise-static page to become dynamic on every request.\n */\n revalidate?: number | false;\n /** Supplies `{{query.\u2026}}` / `{{page.\u2026}}` values for this request. */\n context?: RequestContext;\n};\n\nexport const DEFAULT_REVALIDATE = 60;\n\nexport async function loadSource(\n source: DataSource,\n { revalidate = DEFAULT_REVALIDATE, context = EMPTY_CONTEXT }: LoadOptions = {},\n): Promise<SourceResult> {\n const request = buildRequest(source, context);\n let target = request.url;\n\n let response: Response | undefined;\n for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {\n response = await fetch(target, {\n // Redirects are followed by hand so each hop is validated too.\n redirect: \"manual\",\n method: request.method,\n headers: request.headers,\n body: request.body,\n signal: AbortSignal.timeout(TIMEOUT_MS),\n // Only GET responses are cacheable; anything with side effects is\n // fetched fresh every time.\n ...(revalidate === false || request.method !== \"GET\"\n ? { cache: \"no-store\" as const }\n : { next: { revalidate } }),\n });\n\n if (response.status < 300 || response.status >= 400) break;\n\n const location = response.headers.get(\"location\");\n if (!location) break;\n target = assertFetchableUrl(new URL(location, target).toString());\n response = undefined;\n }\n\n if (!response) throw new DataSourceError(\"Too many redirects.\");\n if (!response.ok) {\n throw new DataSourceError(`The API replied ${response.status}.`, response.status);\n }\n\n const text = await readCapped(response);\n let payload: unknown;\n try {\n payload = JSON.parse(text);\n } catch {\n throw new DataSourceError(\"The response was not JSON.\");\n }\n\n const selected = selectPath(payload, source.path);\n const selectedRows = Array.isArray(selected)\n ? selected\n : selected !== null && typeof selected === \"object\"\n ? [selected]\n : undefined;\n if (!selectedRows) {\n throw new DataSourceError(\n source.path\n ? `\"${source.path}\" is not a list in that response.`\n : \"That response is neither an object nor a list.\",\n );\n }\n\n const rows = selectedRows\n .slice(0, MAX_ROWS)\n .map((row) =>\n row !== null && typeof row === \"object\" && !Array.isArray(row)\n ? (row as Record<string, unknown>)\n : { value: row },\n );\n\n // Union of the keys across the sample, so binding menus list everything.\n const keys = [...new Set(rows.flatMap((row) => Object.keys(row)))].sort();\n return { rows, keys };\n}\n\n/** Reads one binding path off a row and flattens it to something renderable. */\nexport function readBinding(row: Record<string, unknown>, path: string): string {\n const value = selectPath(row, path);\n if (value === null || value === undefined) return \"\";\n if (typeof value === \"object\") return \"\";\n return String(value);\n}\n\n/* ------------------------------------------------------------------ tokens */\n\n/**\n * Resolves `{{query.id}}`, `{{params.slug}}` and `{{page.slug}}`.\n *\n * Only these two namespaces exist: an unknown token becomes an empty string\n * rather than being left in the URL, so a typo cannot send a literal `{{\u2026}}`\n * to the API or accidentally expose something else.\n */\nexport function resolveTokens(value: string, context: RequestContext): string {\n return value.replace(/\\{\\{\\s*([a-zA-Z0-9_.]+)\\s*\\}\\}/g, (_, token: string) => {\n const [namespace, ...rest] = token.split(\".\");\n const key = rest.join(\".\");\n if (namespace === \"query\") return context.query[key] ?? \"\";\n if (namespace === \"params\") return context.params[key] ?? \"\";\n if (namespace === \"page\" && key === \"slug\") return context.page.slug;\n return \"\";\n });\n}\n\n/** Header names and values must not be able to inject extra headers. */\nfunction safeHeader(pair: RequestPair, context: RequestContext) {\n const name = pair.key.trim();\n if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(name)) return null;\n // Strip anything that could terminate the header line.\n const value = resolveTokens(pair.value, context).replace(/[\\r\\n\\0]/g, \"\").trim();\n return value ? ([name, value] as const) : null;\n}\n\n/**\n * Builds the final URL: tokens resolved, params appended and percent-encoded\n * by `URLSearchParams` so a value can never break out into the query grammar.\n */\nexport function buildRequest(source: DataSource, context: RequestContext) {\n const url = assertFetchableUrl(resolveTokens(source.url, context));\n\n for (const pair of source.params ?? []) {\n const key = pair.key.trim();\n if (!key) continue;\n const value = resolveTokens(pair.value, context);\n // An empty optional filter is dropped rather than sent as `key=`.\n if (value === \"\") continue;\n url.searchParams.set(key, value);\n }\n\n const method: HttpMethod = source.method ?? \"GET\";\n\n const headers: Record<string, string> = {\n accept: \"application/json,text/plain;q=0.9,*/*;q=0.8\",\n };\n\n let body: string | undefined;\n if (sendsBody(method) && source.body?.trim()) {\n body = resolveTokens(source.body, context);\n headers[\"content-type\"] = \"application/json\";\n }\n\n // Author headers last, so they can override the defaults above.\n for (const pair of source.headers ?? []) {\n const safe = safeHeader(pair, context);\n if (safe) headers[safe[0]] = safe[1];\n }\n\n // Re-check after tokens: a token could have rewritten the host.\n return { url: assertFetchableUrl(url.toString()), headers, method, body };\n}\n\nexport const EMPTY_CONTEXT: RequestContext = { query: {}, params: {}, page: { slug: \"\" } };\n", "import { DataSourceError, type LoadOptions, loadSource } from \"@/lib/data/source\";\nimport type { CanvasElement, DataSource } from \"@/lib/editor/types\";\nimport type { PageData } from \"./bind\";\n\n/** Signals that a source elected to turn its upstream 404 into a page 404. */\nexport class PageDataNotFoundError extends Error {\n constructor(public readonly sourceId: string) {\n super(`Data source ${sourceId} returned 404.`);\n this.name = \"PageDataNotFoundError\";\n }\n}\n\n/**\n * Fetches every source a page actually uses. Sources nothing references are\n * skipped, and a source that fails yields no rows rather than failing the\n * whole page \u2014 a broken API should not take the site down.\n */\nexport async function loadPageData(\n elements: CanvasElement[],\n sources: DataSource[],\n options?: LoadOptions,\n): Promise<PageData> {\n const used = new Set(\n elements\n .filter((el) => el.sourceId)\n .map((el) => el.sourceId as string),\n );\n const wanted = sources.filter((source) => used.has(source.id) && source.url);\n if (wanted.length === 0) return {};\n\n const results = await Promise.all(\n wanted.map(async (source) => {\n try {\n const { rows } = await loadSource(source, options);\n return [source.id, rows] as const;\n } catch (error) {\n if (\n error instanceof DataSourceError &&\n error.status === 404 &&\n source.onNotFound === \"page-404\"\n ) {\n throw new PageDataNotFoundError(source.id);\n }\n console.warn(\n `Data source \"${source.name}\" failed:`,\n error instanceof DataSourceError ? error.message : error,\n );\n return [source.id, []] as const;\n }\n }),\n );\n\n return Object.fromEntries(results);\n}\n"],
|
|
4
|
+
"sourcesContent": ["export const ELEMENT_TYPES = [\n \"Frame\",\n \"Stack\",\n \"Section\",\n \"Container\",\n \"Grid\",\n \"Heading\",\n \"Text\",\n \"Image\",\n \"Button\",\n \"Video\",\n \"Icon\",\n \"Form\",\n \"Input\",\n \"Textarea\",\n \"Request\",\n \"Repeat\",\n] as const;\n\nexport type ElementType = (typeof ELEMENT_TYPES)[number];\n\n/** One key/value pair on a request; values may carry `{{\u2026}}` tokens. */\nexport type RequestPair = { key: string; value: string };\n\nexport const HTTP_METHODS = [\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\"] as const;\nexport type HttpMethod = (typeof HTTP_METHODS)[number];\n\nexport const DATA_SOURCE_NOT_FOUND_BEHAVIORS = [\"empty\", \"page-404\"] as const;\nexport type DataSourceNotFoundBehavior = (typeof DATA_SOURCE_NOT_FOUND_BEHAVIORS)[number];\n\n/** Methods that carry a request body. */\nexport function sendsBody(method: HttpMethod) {\n return method === \"POST\" || method === \"PUT\" || method === \"PATCH\";\n}\n\n/**\n * A JSON endpoint the page pulls content from. Sources live on the page so a\n * Repeat block can name one without carrying the URL on every element.\n */\nexport type DataSource = {\n id: string;\n name: string;\n url: string;\n /** Dotted path to the array inside the payload; \"\" when it is the root. */\n path: string;\n method?: HttpMethod;\n /** JSON body for POST/PUT/PATCH; tokens are resolved before sending. */\n body?: string;\n /** Appended to the URL as a query string. */\n params?: RequestPair[];\n /** Sent as request headers \u2014 for API keys and the like. */\n headers?: RequestPair[];\n /** Controls whether an upstream 404 empties this source or rejects the whole page. */\n onNotFound?: DataSourceNotFoundBehavior;\n};\n\n/**\n * Values a request token can read. Everything else resolves to an empty\n * string, so a typo cannot leak an unrelated value into the URL.\n */\nexport type RequestContext = {\n /** The visitor's query string, e.g. `{{query.id}}` on /post?id=5. */\n query: Record<string, string>;\n /** Values captured by a dynamic page path, e.g. `{{params.slug}}`. */\n params: Record<string, string>;\n /** The page being rendered, e.g. `{{page.slug}}`. */\n page: { slug: string };\n};\n\nexport type ResizeHandle = \"nw\" | \"ne\" | \"sw\" | \"se\" | \"n\" | \"s\" | \"w\" | \"e\";\n\n/* ------------------------------------------------------------- breakpoints */\n\nexport const BREAKPOINTS = [\"desktop\", \"tablet\", \"mobile\"] as const;\nexport type Breakpoint = string;\n\nexport type BreakpointDefinition = {\n id: string;\n name: string;\n width: number;\n};\n\nexport const DEFAULT_BREAKPOINTS: BreakpointDefinition[] = [\n { id: \"desktop\", name: \"Desktop\", width: 1280 },\n { id: \"tablet\", name: \"Tablet\", width: 768 },\n { id: \"mobile\", name: \"Mobile\", width: 375 },\n];\n\nexport const BREAKPOINT_WIDTHS: Record<string, number> = {\n desktop: 1280,\n tablet: 768,\n mobile: 375,\n};\n\n/**\n * Styles cascade from the widest breakpoint down, so a value set on desktop\n * holds everywhere until a narrower breakpoint overrides it.\n */\nexport const BREAKPOINT_CHAIN: Record<string, Breakpoint[]> = {\n desktop: [\"desktop\"],\n tablet: [\"desktop\", \"tablet\"],\n mobile: [\"desktop\", \"tablet\", \"mobile\"],\n};\n\n/* ------------------------------------------------------------------ styles */\n\n/** `fill` stretches to the parent, `auto` shrinks to the content. */\nexport type SizeMode = \"fixed\" | \"fill\" | \"auto\" | \"screen\";\nexport type Constraint = \"start\" | \"center\" | \"end\" | \"stretch\";\n/** `absolute` positions children by x/y; `stack` lays them out with flexbox. */\nexport type LayoutMode = \"absolute\" | \"stack\";\nexport type Direction = \"row\" | \"column\";\nexport type Justify = \"start\" | \"center\" | \"end\" | \"between\";\nexport type Align = \"start\" | \"center\" | \"end\" | \"stretch\";\nexport type TextAlign = \"left\" | \"center\" | \"right\" | \"justify\";\nexport type TextTransform = \"none\" | \"uppercase\" | \"lowercase\" | \"capitalize\";\nexport type ObjectFit = \"cover\" | \"contain\" | \"fill\" | \"none\";\nexport type Overflow = \"visible\" | \"hidden\" | \"auto\" | \"scroll\";\nexport type Entrance = \"none\" | \"fade\" | \"up\" | \"down\" | \"left\" | \"right\" | \"zoom\";\nexport type MotionCurve = \"ease\" | \"spring\";\nexport type CursorStyle = \"auto\" | \"default\" | \"pointer\" | \"text\" | \"grab\" | \"zoom-in\" | \"none\";\n/**\n * How an element sits relative to its siblings.\n *\n * `absolute` is per element, not per container: it lifts this one out of the\n * flow and places it at x/y while everything around it keeps stacking. Making\n * the whole parent free instead would move every sibling to satisfy one of\n * them.\n */\nexport type PositionMode = \"static\" | \"sticky\" | \"fixed\" | \"absolute\";\n/** Which edge a pinned element holds to. */\nexport type PinSide = \"top\" | \"bottom\" | \"left\" | \"right\";\nexport type BgSize = \"cover\" | \"contain\" | \"auto\";\nexport type BlendMode =\n | \"normal\"\n | \"multiply\"\n | \"screen\"\n | \"overlay\"\n | \"darken\"\n | \"lighten\"\n | \"difference\"\n | \"luminosity\";\nexport type BorderStyle = \"solid\" | \"dashed\" | \"dotted\";\n\nexport type ElementStyle = {\n // Box \u2014 x/y only apply inside an `absolute` parent.\n x: number;\n y: number;\n constraintX: Constraint;\n constraintY: Constraint;\n w: number;\n h: number;\n widthMode: SizeMode;\n heightMode: SizeMode;\n\n // How this element arranges its own children.\n layout: LayoutMode;\n direction: Direction;\n gap: number;\n padT: number;\n padR: number;\n padB: number;\n padL: number;\n /**\n * Space held below the element, outside its own box.\n *\n * Separate from `padB` on purpose: padding is the breathing room the\n * author gave the content inside a section, while this is the distance to\n * whatever comes next. Sharing one value would make adjusting the rhythm\n * between sections quietly reflow their insides.\n */\n marginB: number;\n justify: Justify;\n align: Align;\n wrap: boolean;\n /** Grid columns; only read when `layout` is `stack` on a Grid element. */\n columns: number;\n\n // Appearance\n bg: string;\n /** A full CSS gradient value, or \"\" for none. Painted over `bg`. */\n gradient: string;\n color: string;\n radius: number;\n opacity: number;\n borderW: number;\n /** Per-edge override; null inherits borderW. */\n borderT: number | null;\n borderR: number | null;\n borderB: number | null;\n borderL: number | null;\n borderC: string;\n borderStyle: BorderStyle;\n /** A full CSS box-shadow value, or \"\" for none. */\n shadow: string;\n rotate: number;\n\n // Typography\n fontFamily: string;\n fontSize: number;\n fontWeight: string;\n lineHeight: number;\n letterSpacing: number;\n textAlign: TextAlign;\n textTransform: TextTransform;\n\n // Composition \u2014 the pieces that make a layout feel designed rather than\n // stacked: clipping, sticky rails, imagery, glass and blend effects.\n overflow: Overflow;\n position: PositionMode;\n /** CSS stacking order, independent from the internal document order. */\n zIndex: number;\n /** Distance from `pinSide` while pinned; read when position is sticky or fixed. */\n stickyOffset: number;\n /** Edge a sticky or fixed element pins to. */\n pinSide: PinSide;\n /** Background image URL, or \"\" for none. Painted over `gradient`. */\n bgImage: string;\n bgSize: BgSize;\n bgPosition: string;\n /** Blurs the element's own content, in px. */\n blur: number;\n /** Blurs whatever sits behind the element, in px \u2014 the glass effect. */\n backdropBlur: number;\n blendMode: BlendMode;\n /** Percent; 100 leaves the element alone. */\n scale: number;\n /** A CSS ratio such as \"16/9\", or \"\" to leave height to the layout. */\n aspectRatio: string;\n\n /** Entrance effect, played once when the element scrolls into view. */\n entrance: Entrance;\n /** Milliseconds. */\n entranceDuration: number;\n entranceDelay: number;\n entranceCurve: MotionCurve;\n entranceBezier: string;\n springStiffness: number;\n springDamping: number;\n cursor: CursorStyle;\n\n /** Hidden at this breakpoint. */\n hidden: boolean;\n};\n\nexport const STYLE_KEYS = [\n \"x\",\n \"y\",\n \"constraintX\",\n \"constraintY\",\n \"w\",\n \"h\",\n \"widthMode\",\n \"heightMode\",\n \"layout\",\n \"direction\",\n \"gap\",\n \"padT\",\n \"padR\",\n \"padB\",\n \"padL\",\n \"marginB\",\n \"justify\",\n \"align\",\n \"wrap\",\n \"columns\",\n \"bg\",\n \"gradient\",\n \"color\",\n \"radius\",\n \"opacity\",\n \"borderW\",\n \"borderT\",\n \"borderR\",\n \"borderB\",\n \"borderL\",\n \"borderC\",\n \"borderStyle\",\n \"shadow\",\n \"rotate\",\n \"fontFamily\",\n \"fontSize\",\n \"fontWeight\",\n \"lineHeight\",\n \"letterSpacing\",\n \"textAlign\",\n \"textTransform\",\n \"overflow\",\n \"position\",\n \"zIndex\",\n \"stickyOffset\",\n \"pinSide\",\n \"bgImage\",\n \"bgSize\",\n \"bgPosition\",\n \"blur\",\n \"backdropBlur\",\n \"blendMode\",\n \"scale\",\n \"aspectRatio\",\n \"entrance\",\n \"entranceDuration\",\n \"entranceDelay\",\n \"entranceCurve\",\n \"entranceBezier\",\n \"springStiffness\",\n \"springDamping\",\n \"cursor\",\n \"hidden\",\n] as const satisfies ReadonlyArray<keyof ElementStyle>;\n\nexport type StyleKey = (typeof STYLE_KEYS)[number];\n\nexport type CanvasElement = {\n id: string;\n type: ElementType;\n name?: string;\n parentId?: string;\n z: number;\n locked?: boolean;\n /** Page-local reusable component metadata. */\n componentRole?: \"master\" | \"instance\";\n componentId?: string;\n componentSourceId?: string;\n variant?: string;\n styleBindings?: Partial<Record<StyleKey, string>>;\n\n // Content is shared across breakpoints.\n content?: string;\n /** Sandboxed HTML/CSS used by code components. */\n code?: string;\n src?: string;\n alt?: string;\n objectFit?: ObjectFit;\n iconName?: PagieraIconName;\n placeholder?: string;\n fieldName?: string;\n inputType?: \"text\" | \"email\" | \"password\" | \"number\" | \"tel\" | \"url\" | \"search\";\n required?: boolean;\n formAction?: string;\n formMethod?: HttpMethod;\n formSubmitMode?: \"request\" | \"native\";\n formContentType?: \"json\" | \"form-data\" | \"urlencoded\";\n /** Optional request body. `{{form.email}}` tokens read submitted fields. */\n formBody?: string;\n /** One `Header: value` pair per line. */\n formHeaders?: string;\n formSuccessMessage?: string;\n formErrorMessage?: string;\n formResetOnSuccess?: boolean;\n buttonType?: \"button\" | \"submit\" | \"reset\";\n href?: string;\n target?: \"_self\" | \"_blank\";\n interaction?: {\n trigger: \"click\";\n action: \"navigate\" | \"scroll-to\" | \"toggle-layer\" | \"show-layer\" | \"hide-layer\";\n value: string;\n target?: \"_self\" | \"_blank\";\n };\n\n /** Data source read by a Request/Repeat block or a directly-bound element. */\n sourceId?: string;\n /**\n * Inside Request/Repeat, pulls this field off the current object instead\n * of using the element's content. Dotted paths work: \"author.name\".\n */\n binding?: string;\n\n /** Desktop values; every breakpoint falls back to these. */\n base: ElementStyle;\n /** Narrower-breakpoint deltas, applied over `base` in cascade order. */\n overrides?: Record<string, Partial<ElementStyle>>;\n /** Applied on pointer hover, on top of the resolved breakpoint style. */\n hover?: Partial<ElementStyle>;\n /** Whether hover is activated by this node or its immediate parent. */\n hoverTrigger?: \"self\" | \"parent\";\n /** Applied while the pointer is pressed. */\n press?: Partial<ElementStyle>;\n loop?: { type: \"pulse\" | \"float\" | \"spin\"; duration: number };\n draggable?: boolean;\n};\n\n/* ---------------------------------------------------------------- defaults */\n\nexport const BASE_STYLE: ElementStyle = {\n x: 0,\n y: 0,\n constraintX: \"start\",\n constraintY: \"start\",\n w: 200,\n h: 100,\n widthMode: \"fixed\",\n heightMode: \"fixed\",\n\n layout: \"absolute\",\n direction: \"column\",\n gap: 0,\n padT: 0,\n padR: 0,\n padB: 0,\n padL: 0,\n marginB: 0,\n justify: \"start\",\n align: \"start\",\n wrap: false,\n columns: 3,\n\n bg: \"transparent\",\n gradient: \"\",\n color: \"#27272a\",\n radius: 0,\n opacity: 100,\n borderW: 0,\n borderT: null,\n borderR: null,\n borderB: null,\n borderL: null,\n borderC: \"transparent\",\n borderStyle: \"solid\",\n shadow: \"\",\n rotate: 0,\n\n fontFamily: \"inherit\",\n fontSize: 16,\n fontWeight: \"normal\",\n lineHeight: 1.5,\n letterSpacing: 0,\n textAlign: \"left\",\n textTransform: \"none\",\n\n overflow: \"visible\",\n position: \"static\",\n zIndex: 0,\n stickyOffset: 0,\n pinSide: \"top\",\n bgImage: \"\",\n bgSize: \"cover\",\n bgPosition: \"center\",\n blur: 0,\n backdropBlur: 0,\n blendMode: \"normal\",\n scale: 100,\n aspectRatio: \"\",\n\n entrance: \"none\",\n entranceDuration: 600,\n entranceDelay: 0,\n entranceCurve: \"ease\",\n entranceBezier: \"0.44, 0, 0.56, 1\",\n springStiffness: 300,\n springDamping: 30,\n cursor: \"auto\",\n\n hidden: false,\n};\n\nexport function makeStyle(overrides: Partial<ElementStyle>): ElementStyle {\n return { ...BASE_STYLE, ...overrides };\n}\n\n/** Every field a freshly dropped element starts with. */\nexport const ELEMENT_DEFAULTS: Record<\n ElementType,\n { style: Partial<ElementStyle>; props?: Partial<CanvasElement> }\n> = {\n Frame: {\n style: {\n w: 640,\n h: 420,\n widthMode: \"fixed\",\n heightMode: \"fixed\",\n layout: \"absolute\",\n direction: \"column\",\n overflow: \"hidden\",\n bg: \"#ffffff\",\n borderW: 1,\n borderC: \"#e4e4e7\",\n radius: 12,\n },\n },\n Stack: {\n style: {\n w: 600,\n h: 160,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"column\",\n gap: 16,\n padT: 0,\n padR: 0,\n padB: 0,\n padL: 0,\n justify: \"start\",\n align: \"stretch\",\n bg: \"transparent\",\n },\n },\n Section: {\n style: {\n w: 1280,\n h: 480,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"column\",\n gap: 24,\n padT: 64,\n padR: 48,\n padB: 64,\n padL: 48,\n justify: \"start\",\n align: \"stretch\",\n bg: \"#ffffff\",\n },\n },\n Container: {\n style: {\n w: 600,\n h: 240,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"column\",\n gap: 16,\n padT: 24,\n padR: 24,\n padB: 24,\n padL: 24,\n align: \"stretch\",\n bg: \"transparent\",\n borderW: 1,\n borderC: \"#e4e4e7\",\n radius: 12,\n },\n },\n Grid: {\n style: {\n w: 900,\n h: 300,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"row\",\n columns: 3,\n gap: 24,\n align: \"stretch\",\n },\n },\n Heading: {\n style: {\n w: 480,\n h: 48,\n widthMode: \"fill\",\n heightMode: \"auto\",\n fontSize: 40,\n fontWeight: \"bold\",\n lineHeight: 1.2,\n letterSpacing: -0.5,\n color: \"#18181b\",\n },\n props: { content: \"Heading\" },\n },\n Text: {\n style: {\n w: 480,\n h: 24,\n widthMode: \"fill\",\n heightMode: \"auto\",\n fontSize: 16,\n lineHeight: 1.6,\n color: \"#52525b\",\n },\n props: { content: \"Text block content\" },\n },\n Image: {\n style: { w: 400, h: 260, widthMode: \"fill\", bg: \"#e4e4e7\", radius: 12 },\n props: { src: \"\", alt: \"\", objectFit: \"cover\" },\n },\n Button: {\n style: {\n w: 140,\n h: 44,\n widthMode: \"auto\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"row\",\n justify: \"center\",\n align: \"center\",\n padT: 12,\n padR: 22,\n padB: 12,\n padL: 22,\n bg: \"#2563eb\",\n color: \"#ffffff\",\n radius: 8,\n fontSize: 15,\n fontWeight: \"500\",\n textAlign: \"center\",\n },\n props: { content: \"Button\", href: \"\", target: \"_self\", buttonType: \"button\" },\n },\n Video: {\n style: { w: 640, h: 360, widthMode: \"fill\", bg: \"#18181b\", radius: 12 },\n props: { src: \"\" },\n },\n Icon: {\n style: { w: 24, h: 24, widthMode: \"fixed\", heightMode: \"fixed\", color: \"#5402E6\" },\n props: { iconName: \"star\" },\n },\n Form: {\n style: {\n w: 560,\n h: 240,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"column\",\n gap: 14,\n align: \"stretch\",\n },\n props: {\n formAction: \"\",\n formMethod: \"POST\",\n formSubmitMode: \"request\",\n formContentType: \"json\",\n formSuccessMessage: \"Sent successfully.\",\n formErrorMessage: \"Something went wrong.\",\n },\n },\n Input: {\n style: {\n w: 320,\n h: 46,\n widthMode: \"fill\",\n heightMode: \"fixed\",\n padT: 0,\n padR: 14,\n padB: 0,\n padL: 14,\n bg: \"#ffffff\",\n color: \"#18181b\",\n borderW: 1,\n borderC: \"#d4d4d8\",\n radius: 10,\n fontSize: 15,\n },\n props: { placeholder: \"Enter a value\u2026\", fieldName: \"field\", inputType: \"text\" },\n },\n Textarea: {\n style: {\n w: 320,\n h: 120,\n widthMode: \"fill\",\n heightMode: \"fixed\",\n padT: 12,\n padR: 14,\n padB: 12,\n padL: 14,\n bg: \"#ffffff\",\n color: \"#18181b\",\n borderW: 1,\n borderC: \"#d4d4d8\",\n radius: 10,\n fontSize: 15,\n },\n props: { placeholder: \"Write your message\u2026\", fieldName: \"message\" },\n },\n Request: {\n style: {\n w: 900,\n h: 200,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"column\",\n gap: 16,\n align: \"stretch\",\n },\n props: { sourceId: \"\" },\n },\n Repeat: {\n style: {\n w: 900,\n h: 200,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"row\",\n gap: 24,\n align: \"stretch\",\n wrap: true,\n },\n props: { sourceId: \"\" },\n },\n};\n\n/* ----------------------------------------------------------------- helpers */\n\nconst CONTAINER_TYPES: ReadonlySet<ElementType> = new Set([\n \"Frame\",\n \"Stack\",\n \"Section\",\n \"Container\",\n \"Grid\",\n \"Button\",\n \"Form\",\n \"Request\",\n \"Repeat\",\n]);\n\n/** Whether an element can hold children. */\nexport function isContainer(type: ElementType) {\n return CONTAINER_TYPES.has(type);\n}\n\nconst TEXTUAL_TYPES: ReadonlySet<ElementType> = new Set([\n \"Heading\",\n \"Text\",\n \"Button\",\n]);\n\n/** Whether an element renders editable text. */\nexport function isTextual(type: ElementType) {\n return TEXTUAL_TYPES.has(type);\n}\n\n/** Page-level settings; the canvas behaves as the root container. */\nexport type RootStyle = {\n documentMode: \"page\" | \"component\";\n /** Content is centred inside this width unless `fullWidth` is set. */\n maxWidth: number;\n /** Editable design-surface height; the public page can still grow with content. */\n canvasHeight: number;\n /** Let content run edge to edge instead of being capped at `maxWidth`. */\n fullWidth: boolean;\n bg: string;\n layout: LayoutMode;\n direction: Direction;\n gap: number;\n padT: number;\n padR: number;\n padB: number;\n padL: number;\n align: Align;\n fontFamily: string;\n /** Cross-document animation used by links on the published site. */\n pageTransition: \"smooth\" | \"fade\" | \"slide\" | \"none\";\n /** Duration of the incoming published-page animation in milliseconds. */\n pageTransitionDuration: number;\n /** User-defined viewport previews, ordered from widest to narrowest. */\n breakpoints?: BreakpointDefinition[];\n /** The breakpoint whose values live in every element's `base` style. */\n baseBreakpointId?: string;\n variables?: DesignVariable[];\n customFonts?: CustomFont[];\n /**\n * Raw CSS appended after the generated sheet, so it can override any rule\n * the builder produced. Escape hatch for what the inspector cannot express.\n */\n customCss?: string;\n /**\n * Raw JavaScript run on the published page, after the document parses.\n *\n * It is deliberately never executed inside the editor: the canvas and the\n * template preview both render without scripting, so a page cannot reach\n * the editor it is being built in.\n */\n customJs?: string;\n};\n\nexport type CustomFont = { id: string; name: string; url: string; weight: number; style: \"normal\" | \"italic\" };\n\nexport type DesignVariable = {\n id: string;\n name: string;\n type: \"color\" | \"number\";\n value: string | number;\n};\n\nexport const DEFAULT_ROOT_STYLE: RootStyle = {\n documentMode: \"page\",\n maxWidth: 1280,\n canvasHeight: 800,\n fullWidth: false,\n bg: \"#ffffff\",\n layout: \"stack\",\n direction: \"column\",\n gap: 0,\n padT: 0,\n padR: 0,\n padB: 0,\n padL: 0,\n align: \"stretch\",\n fontFamily: \"inherit\",\n pageTransition: \"smooth\",\n pageTransitionDuration: 380,\n};\n\nexport const FONT_STACKS: Array<{ label: string; value: string }> = [\n { label: \"Inherit\", value: \"inherit\" },\n { label: \"Sans\", value: \"ui-sans-serif, system-ui, sans-serif\" },\n { label: \"Serif\", value: \"ui-serif, Georgia, serif\" },\n { label: \"Mono\", value: \"ui-monospace, SFMono-Regular, monospace\" },\n];\n\nexport const SHADOW_PRESETS: Array<{ label: string; value: string }> = [\n { label: \"None\", value: \"\" },\n { label: \"Small\", value: \"0 1px 2px rgba(0,0,0,0.06)\" },\n { label: \"Medium\", value: \"0 4px 12px rgba(0,0,0,0.08)\" },\n { label: \"Large\", value: \"0 12px 32px rgba(0,0,0,0.12)\" },\n { label: \"Glow\", value: \"0 0 0 4px rgba(37,99,235,0.15)\" },\n];\n\nexport const ASPECT_RATIOS: Array<{ label: string; value: string }> = [\n { label: \"Free\", value: \"\" },\n { label: \"Square 1:1\", value: \"1/1\" },\n { label: \"Photo 4:3\", value: \"4/3\" },\n { label: \"Wide 16:9\", value: \"16/9\" },\n { label: \"Ultra 21:9\", value: \"21/9\" },\n { label: \"Portrait 3:4\", value: \"3/4\" },\n];\n\nexport const ENTRANCES: Array<{ label: string; value: Entrance }> = [\n { label: \"None\", value: \"none\" },\n { label: \"Fade in\", value: \"fade\" },\n { label: \"Rise up\", value: \"up\" },\n { label: \"Drop down\", value: \"down\" },\n { label: \"Slide from left\", value: \"left\" },\n { label: \"Slide from right\", value: \"right\" },\n { label: \"Zoom in\", value: \"zoom\" },\n];\n\nexport const DRAG_MIME = \"application/pagiera-element-type\";\n/** Set when dragging an element that already exists on the canvas. */\nexport const MOVE_MIME = \"application/pagiera-element-id\";\nimport type { PagieraIconName } from \"../../../icon-names\";\n", "import {\n type DataSource,\n type HttpMethod,\n type RequestContext,\n type RequestPair,\n sendsBody,\n} from \"@/lib/editor/types\";\n\n/**\n * Fetching an author-supplied URL from our server is a server-side request\n * forgery vector: without checks it can reach cloud metadata endpoints, admin\n * services on localhost, or anything else inside the network perimeter. Every\n * hop is validated against this list, and redirects are followed by hand so a\n * public URL cannot bounce into private space.\n */\nconst BLOCKED_HOSTS = new Set([\n \"localhost\",\n \"127.0.0.1\",\n \"0.0.0.0\",\n \"::1\",\n \"[::1]\",\n \"metadata.google.internal\",\n \"metadata.goog\",\n]);\n\nconst PRIVATE_IPV4 =\n /^(10\\.|127\\.|0\\.|169\\.254\\.|192\\.168\\.|172\\.(1[6-9]|2\\d|3[01])\\.)/;\n\n/** Hostnames that only resolve inside a private network. */\nconst PRIVATE_SUFFIXES = [\".local\", \".internal\", \".localdomain\", \".home.arpa\"];\n\nexport const MAX_ROWS = 200;\nconst MAX_BYTES = 2_000_000;\nconst TIMEOUT_MS = 8000;\nconst MAX_REDIRECTS = 3;\n\nexport class DataSourceError extends Error {\n constructor(message: string, public readonly status?: number) {\n super(message);\n this.name = \"DataSourceError\";\n }\n}\n\nexport function assertFetchableUrl(raw: string): URL {\n let url: URL;\n try {\n url = new URL(raw);\n } catch {\n throw new DataSourceError(\"That is not a valid URL.\");\n }\n\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") {\n throw new DataSourceError(\"Only http and https URLs can be requested.\");\n }\n\n const host = url.hostname.toLowerCase();\n if (BLOCKED_HOSTS.has(host) || PRIVATE_IPV4.test(host)) {\n throw new DataSourceError(`Requests to ${host} are not allowed.`);\n }\n if (PRIVATE_SUFFIXES.some((suffix) => host.endsWith(suffix))) {\n throw new DataSourceError(`Requests to ${host} are not allowed.`);\n }\n // Bracketed IPv6 literals: anything link-local or unique-local.\n if (host.startsWith(\"[\") && /^\\[(fe80|fc|fd)/i.test(host)) {\n throw new DataSourceError(\"Requests to private addresses are not allowed.\");\n }\n\n return url;\n}\n\n/**\n * Walks a dotted path such as `data.items` or `results.0.tags`. An empty path\n * returns the payload itself, which is what a bare JSON array needs.\n */\nexport function selectPath(payload: unknown, path: string): unknown {\n const steps = path.split(\".\").map((s) => s.trim()).filter(Boolean);\n let cursor: unknown = payload;\n for (const step of steps) {\n if (cursor === null || typeof cursor !== \"object\") return undefined;\n cursor = (cursor as Record<string, unknown>)[step];\n }\n return cursor;\n}\n\n/** Reads a body with a hard cap so a huge response cannot exhaust memory. */\nasync function readCapped(response: Response): Promise<string> {\n const reader = response.body?.getReader();\n if (!reader) return \"\";\n\n const chunks: Uint8Array[] = [];\n let total = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n total += value.byteLength;\n if (total > MAX_BYTES) {\n await reader.cancel();\n throw new DataSourceError(\"That response is too large (over 2 MB).\");\n }\n chunks.push(value);\n }\n return new TextDecoder().decode(\n chunks.reduce<Uint8Array>((acc, chunk) => {\n const merged = new Uint8Array(acc.length + chunk.length);\n merged.set(acc);\n merged.set(chunk, acc.length);\n return merged;\n }, new Uint8Array()),\n );\n}\n\nexport type SourceResult = { rows: Array<Record<string, unknown>>; keys: string[] };\n\n/**\n * Requests a source and normalizes it to rows. A root object becomes one row\n * for Request; an array stays multiple rows for Repeat.\n */\nexport type LoadOptions = {\n /**\n * Seconds to cache the response for. `false` bypasses the cache \u2014 only for\n * the editor's Test button; on a rendered route it would force an\n * otherwise-static page to become dynamic on every request.\n */\n revalidate?: number | false;\n /** Supplies `{{query.\u2026}}` / `{{page.\u2026}}` values for this request. */\n context?: RequestContext;\n};\n\nexport const DEFAULT_REVALIDATE = 60;\n\nexport async function loadSource(\n source: DataSource,\n { revalidate = DEFAULT_REVALIDATE, context = EMPTY_CONTEXT }: LoadOptions = {},\n): Promise<SourceResult> {\n const request = buildRequest(source, context);\n let target = request.url;\n\n let response: Response | undefined;\n for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {\n response = await fetch(target, {\n // Redirects are followed by hand so each hop is validated too.\n redirect: \"manual\",\n method: request.method,\n headers: request.headers,\n body: request.body,\n signal: AbortSignal.timeout(TIMEOUT_MS),\n // Only GET responses are cacheable; anything with side effects is\n // fetched fresh every time.\n ...(revalidate === false || request.method !== \"GET\"\n ? { cache: \"no-store\" as const }\n : { next: { revalidate } }),\n });\n\n if (response.status < 300 || response.status >= 400) break;\n\n const location = response.headers.get(\"location\");\n if (!location) break;\n target = assertFetchableUrl(new URL(location, target).toString());\n response = undefined;\n }\n\n if (!response) throw new DataSourceError(\"Too many redirects.\");\n if (!response.ok) {\n throw new DataSourceError(`The API replied ${response.status}.`, response.status);\n }\n\n const text = await readCapped(response);\n let payload: unknown;\n try {\n payload = JSON.parse(text);\n } catch {\n throw new DataSourceError(\"The response was not JSON.\");\n }\n\n const selected = selectPath(payload, source.path);\n const selectedRows = Array.isArray(selected)\n ? selected\n : selected !== null && typeof selected === \"object\"\n ? [selected]\n : undefined;\n if (!selectedRows) {\n throw new DataSourceError(\n source.path\n ? `\"${source.path}\" is not a list in that response.`\n : \"That response is neither an object nor a list.\",\n );\n }\n\n const rows = selectedRows\n .slice(0, MAX_ROWS)\n .map((row) =>\n row !== null && typeof row === \"object\" && !Array.isArray(row)\n ? (row as Record<string, unknown>)\n : { value: row },\n );\n\n // Union of the keys across the sample, so binding menus list everything.\n const keys = [...new Set(rows.flatMap((row) => Object.keys(row)))].sort();\n return { rows, keys };\n}\n\n/** Reads one binding path off a row and flattens it to something renderable. */\nexport function readBinding(row: Record<string, unknown>, path: string): string {\n const value = selectPath(row, path);\n if (value === null || value === undefined) return \"\";\n if (typeof value === \"object\") return \"\";\n return String(value);\n}\n\n/* ------------------------------------------------------------------ tokens */\n\n/**\n * Resolves `{{query.id}}`, `{{params.slug}}` and `{{page.slug}}`.\n *\n * Only these two namespaces exist: an unknown token becomes an empty string\n * rather than being left in the URL, so a typo cannot send a literal `{{\u2026}}`\n * to the API or accidentally expose something else.\n */\nexport function resolveTokens(value: string, context: RequestContext): string {\n return value.replace(/\\{\\{\\s*([a-zA-Z0-9_.]+)\\s*\\}\\}/g, (_, token: string) => {\n const [namespace, ...rest] = token.split(\".\");\n const key = rest.join(\".\");\n if (namespace === \"query\") return context.query[key] ?? \"\";\n if (namespace === \"params\") return context.params[key] ?? \"\";\n if (namespace === \"page\" && key === \"slug\") return context.page.slug;\n return \"\";\n });\n}\n\n/** Header names and values must not be able to inject extra headers. */\nfunction safeHeader(pair: RequestPair, context: RequestContext) {\n const name = pair.key.trim();\n if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(name)) return null;\n // Strip anything that could terminate the header line.\n const value = resolveTokens(pair.value, context).replace(/[\\r\\n\\0]/g, \"\").trim();\n return value ? ([name, value] as const) : null;\n}\n\n/**\n * Builds the final URL: tokens resolved, params appended and percent-encoded\n * by `URLSearchParams` so a value can never break out into the query grammar.\n */\nexport function buildRequest(source: DataSource, context: RequestContext) {\n const url = assertFetchableUrl(resolveTokens(source.url, context));\n\n for (const pair of source.params ?? []) {\n const key = pair.key.trim();\n if (!key) continue;\n const value = resolveTokens(pair.value, context);\n // An empty optional filter is dropped rather than sent as `key=`.\n if (value === \"\") continue;\n url.searchParams.set(key, value);\n }\n\n const method: HttpMethod = source.method ?? \"GET\";\n\n const headers: Record<string, string> = {\n accept: \"application/json,text/plain;q=0.9,*/*;q=0.8\",\n };\n\n let body: string | undefined;\n if (sendsBody(method) && source.body?.trim()) {\n body = resolveTokens(source.body, context);\n headers[\"content-type\"] = \"application/json\";\n }\n\n // Author headers last, so they can override the defaults above.\n for (const pair of source.headers ?? []) {\n const safe = safeHeader(pair, context);\n if (safe) headers[safe[0]] = safe[1];\n }\n\n // Re-check after tokens: a token could have rewritten the host.\n return { url: assertFetchableUrl(url.toString()), headers, method, body };\n}\n\nexport const EMPTY_CONTEXT: RequestContext = { query: {}, params: {}, page: { slug: \"\" } };\n", "import { DataSourceError, type LoadOptions, loadSource } from \"@/lib/data/source\";\nimport type { CanvasElement, DataSource } from \"@/lib/editor/types\";\nimport type { PageData } from \"./bind\";\n\n/** Signals that a source elected to turn its upstream 404 into a page 404. */\nexport class PageDataNotFoundError extends Error {\n constructor(public readonly sourceId: string) {\n super(`Data source ${sourceId} returned 404.`);\n this.name = \"PageDataNotFoundError\";\n }\n}\n\n/**\n * Fetches every source a page actually uses. Sources nothing references are\n * skipped, and a source that fails yields no rows rather than failing the\n * whole page \u2014 a broken API should not take the site down.\n */\nexport async function loadPageData(\n elements: CanvasElement[],\n sources: DataSource[],\n options?: LoadOptions,\n): Promise<PageData> {\n const used = new Set(\n elements\n .filter((el) => el.sourceId)\n .map((el) => el.sourceId as string),\n );\n const wanted = sources.filter((source) => used.has(source.id) && source.url);\n if (wanted.length === 0) return {};\n\n const results = await Promise.all(\n wanted.map(async (source) => {\n try {\n const { rows } = await loadSource(source, options);\n return [source.id, rows] as const;\n } catch (error) {\n if (\n error instanceof DataSourceError &&\n error.status === 404 &&\n source.onNotFound === \"page-404\"\n ) {\n throw new PageDataNotFoundError(source.id);\n }\n console.warn(\n `Data source \"${source.name}\" failed:`,\n error instanceof DataSourceError ? error.message : error,\n );\n return [source.id, []] as const;\n }\n }),\n );\n\n return Object.fromEntries(results);\n}\n"],
|
|
5
5
|
"mappings": ";AA+BO,SAAS,UAAU,QAAoB;AAC1C,SAAO,WAAW,UAAU,WAAW,SAAS,WAAW;AAC/D;;;AClBA,IAAM,gBAAgB,oBAAI,IAAI;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACJ,CAAC;AAED,IAAM,eACF;AAGJ,IAAM,mBAAmB,CAAC,UAAU,aAAa,gBAAgB,YAAY;AAEtE,IAAM,WAAW;AACxB,IAAM,YAAY;AAClB,IAAM,aAAa;AACnB,IAAM,gBAAgB;AAEf,IAAM,kBAAN,cAA8B,MAAM;AAAA,EACvC,YAAY,SAAiC,QAAiB;AAC1D,UAAM,OAAO;AAD4B;AAEzC,SAAK,OAAO;AAAA,EAChB;AACJ;AAEO,SAAS,mBAAmB,KAAkB;AACjD,MAAI;AACJ,MAAI;AACA,UAAM,IAAI,IAAI,GAAG;AAAA,EACrB,QAAQ;AACJ,UAAM,IAAI,gBAAgB,0BAA0B;AAAA,EACxD;AAEA,MAAI,IAAI,aAAa,YAAY,IAAI,aAAa,SAAS;AACvD,UAAM,IAAI,gBAAgB,4CAA4C;AAAA,EAC1E;AAEA,QAAM,OAAO,IAAI,SAAS,YAAY;AACtC,MAAI,cAAc,IAAI,IAAI,KAAK,aAAa,KAAK,IAAI,GAAG;AACpD,UAAM,IAAI,gBAAgB,eAAe,IAAI,mBAAmB;AAAA,EACpE;AACA,MAAI,iBAAiB,KAAK,CAAC,WAAW,KAAK,SAAS,MAAM,CAAC,GAAG;AAC1D,UAAM,IAAI,gBAAgB,eAAe,IAAI,mBAAmB;AAAA,EACpE;AAEA,MAAI,KAAK,WAAW,GAAG,KAAK,mBAAmB,KAAK,IAAI,GAAG;AACvD,UAAM,IAAI,gBAAgB,gDAAgD;AAAA,EAC9E;AAEA,SAAO;AACX;AAMO,SAAS,WAAW,SAAkB,MAAuB;AAChE,QAAM,QAAQ,KAAK,MAAM,GAAG,EAAE,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,OAAO;AACjE,MAAI,SAAkB;AACtB,aAAW,QAAQ,OAAO;AACtB,QAAI,WAAW,QAAQ,OAAO,WAAW,SAAU,QAAO;AAC1D,aAAU,OAAmC,IAAI;AAAA,EACrD;AACA,SAAO;AACX;AAGA,eAAe,WAAW,UAAqC;AAC3D,QAAM,SAAS,SAAS,MAAM,UAAU;AACxC,MAAI,CAAC,OAAQ,QAAO;AAEpB,QAAM,SAAuB,CAAC;AAC9B,MAAI,QAAQ;AACZ,SAAO,MAAM;AACT,UAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK;AAC1C,QAAI,KAAM;AACV,aAAS,MAAM;AACf,QAAI,QAAQ,WAAW;AACnB,YAAM,OAAO,OAAO;AACpB,YAAM,IAAI,gBAAgB,yCAAyC;AAAA,IACvE;AACA,WAAO,KAAK,KAAK;AAAA,EACrB;AACA,SAAO,IAAI,YAAY,EAAE;AAAA,IACrB,OAAO,OAAmB,CAAC,KAAK,UAAU;AACtC,YAAM,SAAS,IAAI,WAAW,IAAI,SAAS,MAAM,MAAM;AACvD,aAAO,IAAI,GAAG;AACd,aAAO,IAAI,OAAO,IAAI,MAAM;AAC5B,aAAO;AAAA,IACX,GAAG,IAAI,WAAW,CAAC;AAAA,EACvB;AACJ;AAmBO,IAAM,qBAAqB;AAElC,eAAsB,WAClB,QACA,EAAE,aAAa,oBAAoB,UAAU,cAAc,IAAiB,CAAC,GACxD;AACrB,QAAM,UAAU,aAAa,QAAQ,OAAO;AAC5C,MAAI,SAAS,QAAQ;AAErB,MAAI;AACJ,WAAS,MAAM,GAAG,OAAO,eAAe,OAAO;AAC3C,eAAW,MAAM,MAAM,QAAQ;AAAA;AAAA,MAE3B,UAAU;AAAA,MACV,QAAQ,QAAQ;AAAA,MAChB,SAAS,QAAQ;AAAA,MACjB,MAAM,QAAQ;AAAA,MACd,QAAQ,YAAY,QAAQ,UAAU;AAAA;AAAA;AAAA,MAGtC,GAAI,eAAe,SAAS,QAAQ,WAAW,QACzC,EAAE,OAAO,WAAoB,IAC7B,EAAE,MAAM,EAAE,WAAW,EAAE;AAAA,IACjC,CAAC;AAED,QAAI,SAAS,SAAS,OAAO,SAAS,UAAU,IAAK;AAErD,UAAM,WAAW,SAAS,QAAQ,IAAI,UAAU;AAChD,QAAI,CAAC,SAAU;AACf,aAAS,mBAAmB,IAAI,IAAI,UAAU,MAAM,EAAE,SAAS,CAAC;AAChE,eAAW;AAAA,EACf;AAEA,MAAI,CAAC,SAAU,OAAM,IAAI,gBAAgB,qBAAqB;AAC9D,MAAI,CAAC,SAAS,IAAI;AACd,UAAM,IAAI,gBAAgB,mBAAmB,SAAS,MAAM,KAAK,SAAS,MAAM;AAAA,EACpF;AAEA,QAAM,OAAO,MAAM,WAAW,QAAQ;AACtC,MAAI;AACJ,MAAI;AACA,cAAU,KAAK,MAAM,IAAI;AAAA,EAC7B,QAAQ;AACJ,UAAM,IAAI,gBAAgB,4BAA4B;AAAA,EAC1D;AAEA,QAAM,WAAW,WAAW,SAAS,OAAO,IAAI;AAChD,QAAM,eAAe,MAAM,QAAQ,QAAQ,IACrC,WACA,aAAa,QAAQ,OAAO,aAAa,WACvC,CAAC,QAAQ,IACT;AACR,MAAI,CAAC,cAAc;AACf,UAAM,IAAI;AAAA,MACN,OAAO,OACD,IAAI,OAAO,IAAI,sCACf;AAAA,IACV;AAAA,EACJ;AAEA,QAAM,OAAO,aACR,MAAM,GAAG,QAAQ,EACjB;AAAA,IAAI,CAAC,QACF,QAAQ,QAAQ,OAAO,QAAQ,YAAY,CAAC,MAAM,QAAQ,GAAG,IACtD,MACD,EAAE,OAAO,IAAI;AAAA,EACvB;AAGJ,QAAM,OAAO,CAAC,GAAG,IAAI,IAAI,KAAK,QAAQ,CAAC,QAAQ,OAAO,KAAK,GAAG,CAAC,CAAC,CAAC,EAAE,KAAK;AACxE,SAAO,EAAE,MAAM,KAAK;AACxB;AAmBO,SAAS,cAAc,OAAe,SAAiC;AAC1E,SAAO,MAAM,QAAQ,mCAAmC,CAAC,GAAG,UAAkB;AAC1E,UAAM,CAAC,WAAW,GAAG,IAAI,IAAI,MAAM,MAAM,GAAG;AAC5C,UAAM,MAAM,KAAK,KAAK,GAAG;AACzB,QAAI,cAAc,QAAS,QAAO,QAAQ,MAAM,GAAG,KAAK;AACxD,QAAI,cAAc,SAAU,QAAO,QAAQ,OAAO,GAAG,KAAK;AAC1D,QAAI,cAAc,UAAU,QAAQ,OAAQ,QAAO,QAAQ,KAAK;AAChE,WAAO;AAAA,EACX,CAAC;AACL;AAGA,SAAS,WAAW,MAAmB,SAAyB;AAC5D,QAAM,OAAO,KAAK,IAAI,KAAK;AAC3B,MAAI,CAAC,gCAAgC,KAAK,IAAI,EAAG,QAAO;AAExD,QAAM,QAAQ,cAAc,KAAK,OAAO,OAAO,EAAE,QAAQ,aAAa,EAAE,EAAE,KAAK;AAC/E,SAAO,QAAS,CAAC,MAAM,KAAK,IAAc;AAC9C;AAMO,SAAS,aAAa,QAAoB,SAAyB;AACtE,QAAM,MAAM,mBAAmB,cAAc,OAAO,KAAK,OAAO,CAAC;AAEjE,aAAW,QAAQ,OAAO,UAAU,CAAC,GAAG;AACpC,UAAM,MAAM,KAAK,IAAI,KAAK;AAC1B,QAAI,CAAC,IAAK;AACV,UAAM,QAAQ,cAAc,KAAK,OAAO,OAAO;AAE/C,QAAI,UAAU,GAAI;AAClB,QAAI,aAAa,IAAI,KAAK,KAAK;AAAA,EACnC;AAEA,QAAM,SAAqB,OAAO,UAAU;AAE5C,QAAM,UAAkC;AAAA,IACpC,QAAQ;AAAA,EACZ;AAEA,MAAI;AACJ,MAAI,UAAU,MAAM,KAAK,OAAO,MAAM,KAAK,GAAG;AAC1C,WAAO,cAAc,OAAO,MAAM,OAAO;AACzC,YAAQ,cAAc,IAAI;AAAA,EAC9B;AAGA,aAAW,QAAQ,OAAO,WAAW,CAAC,GAAG;AACrC,UAAM,OAAO,WAAW,MAAM,OAAO;AACrC,QAAI,KAAM,SAAQ,KAAK,CAAC,CAAC,IAAI,KAAK,CAAC;AAAA,EACvC;AAGA,SAAO,EAAE,KAAK,mBAAmB,IAAI,SAAS,CAAC,GAAG,SAAS,QAAQ,KAAK;AAC5E;AAEO,IAAM,gBAAgC,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,GAAG,MAAM,EAAE,MAAM,GAAG,EAAE;;;AC/QlF,IAAM,wBAAN,cAAoC,MAAM;AAAA,EAC7C,YAA4B,UAAkB;AAC1C,UAAM,eAAe,QAAQ,gBAAgB;AADrB;AAExB,SAAK,OAAO;AAAA,EAChB;AACJ;AAOA,eAAsB,aAClB,UACA,SACA,SACiB;AACjB,QAAM,OAAO,IAAI;AAAA,IACb,SACK,OAAO,CAAC,OAAO,GAAG,QAAQ,EAC1B,IAAI,CAAC,OAAO,GAAG,QAAkB;AAAA,EAC1C;AACA,QAAM,SAAS,QAAQ,OAAO,CAAC,WAAW,KAAK,IAAI,OAAO,EAAE,KAAK,OAAO,GAAG;AAC3E,MAAI,OAAO,WAAW,EAAG,QAAO,CAAC;AAEjC,QAAM,UAAU,MAAM,QAAQ;AAAA,IAC1B,OAAO,IAAI,OAAO,WAAW;AACzB,UAAI;AACA,cAAM,EAAE,KAAK,IAAI,MAAM,WAAW,QAAQ,OAAO;AACjD,eAAO,CAAC,OAAO,IAAI,IAAI;AAAA,MAC3B,SAAS,OAAO;AACZ,YACI,iBAAiB,mBACjB,MAAM,WAAW,OACjB,OAAO,eAAe,YACxB;AACE,gBAAM,IAAI,sBAAsB,OAAO,EAAE;AAAA,QAC7C;AACA,gBAAQ;AAAA,UACJ,gBAAgB,OAAO,IAAI;AAAA,UAC3B,iBAAiB,kBAAkB,MAAM,UAAU;AAAA,QACvD;AACA,eAAO,CAAC,OAAO,IAAI,CAAC,CAAC;AAAA,MACzB;AAAA,IACJ,CAAC;AAAA,EACL;AAEA,SAAO,OAAO,YAAY,OAAO;AACrC;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/full-editor.js
CHANGED
|
@@ -4112,6 +4112,11 @@ function applySize(css, axis, mode, value, isMainAxis, parentAlign = "start") {
|
|
|
4112
4112
|
if (isMainAxis) css.flexShrink = 0;
|
|
4113
4113
|
return;
|
|
4114
4114
|
}
|
|
4115
|
+
if (mode === "screen") {
|
|
4116
|
+
css[axis] = axis === "height" ? "100vh" : "100vw";
|
|
4117
|
+
if (isMainAxis) css.flexShrink = 0;
|
|
4118
|
+
return;
|
|
4119
|
+
}
|
|
4115
4120
|
if (mode === "auto") {
|
|
4116
4121
|
css[axis] = "auto";
|
|
4117
4122
|
if (isMainAxis) css.flexGrow = 0;
|
|
@@ -4137,6 +4142,8 @@ function splitBand(css, contentWidth) {
|
|
|
4137
4142
|
flexWrap: css.flexWrap,
|
|
4138
4143
|
gridTemplateColumns: css.gridTemplateColumns,
|
|
4139
4144
|
width: "100%",
|
|
4145
|
+
height: css.height,
|
|
4146
|
+
minHeight: css.minHeight,
|
|
4140
4147
|
maxWidth: contentWidth,
|
|
4141
4148
|
marginLeft: "auto",
|
|
4142
4149
|
marginRight: "auto"
|
|
@@ -4571,6 +4578,10 @@ function interactionDeclarations(element, byId, breakpoint, rootStyle, cascade,
|
|
|
4571
4578
|
}
|
|
4572
4579
|
return declarationsToCss(delta);
|
|
4573
4580
|
}
|
|
4581
|
+
function hoverSelector(element, byId) {
|
|
4582
|
+
const parent = element.hoverTrigger === "parent" && element.parentId ? byId.get(element.parentId) : void 0;
|
|
4583
|
+
return parent ? `.${classFor(parent.id)}:hover .${classFor(element.id)}` : `.${classFor(element.id)}:hover`;
|
|
4584
|
+
}
|
|
4574
4585
|
var ENTRANCE_KEYFRAMES = `
|
|
4575
4586
|
@keyframes pg-fade{from{opacity:0}to{opacity:1}}
|
|
4576
4587
|
@keyframes pg-up{from{opacity:0;translate:0 28px}to{opacity:1;translate:none}}
|
|
@@ -4684,7 +4695,7 @@ function stylesheetFor(elements, rootStyle) {
|
|
|
4684
4695
|
}
|
|
4685
4696
|
for (const element of elements) {
|
|
4686
4697
|
if (element.hover || element.press) parts.push(`.${classFor(element.id)}{transition:transform .42s cubic-bezier(.16,1,.3,1),scale .42s cubic-bezier(.16,1,.3,1),rotate .42s cubic-bezier(.16,1,.3,1),translate .42s cubic-bezier(.16,1,.3,1),background-color .32s ease,color .32s ease,border-color .32s ease,box-shadow .42s cubic-bezier(.16,1,.3,1),opacity .32s ease,filter .42s ease;will-change:transform}`);
|
|
4687
|
-
if (element.hover && Object.keys(element.hover).length) parts.push(
|
|
4698
|
+
if (element.hover && Object.keys(element.hover).length) parts.push(`${hoverSelector(element, byId)}{${interactionDeclarations(element, byId, baseId, rootStyle, cascade, element.hover)}}`);
|
|
4688
4699
|
if (element.press && Object.keys(element.press).length) parts.push(`.${classFor(element.id)}:active{${interactionDeclarations(element, byId, baseId, rootStyle, cascade, element.press)}}`);
|
|
4689
4700
|
if (element.loop) {
|
|
4690
4701
|
const name = element.loop.type;
|
|
@@ -4698,7 +4709,7 @@ function stylesheetFor(elements, rootStyle) {
|
|
|
4698
4709
|
if (!element.overrides?.[plan.id] && !parent?.overrides?.[plan.id]) return [];
|
|
4699
4710
|
const selector = `.${classFor(element.id)}`;
|
|
4700
4711
|
return [
|
|
4701
|
-
element.hover && Object.keys(element.hover).length ? `${
|
|
4712
|
+
element.hover && Object.keys(element.hover).length ? `${hoverSelector(element, byId)}{${interactionDeclarations(element, byId, plan.id, rootStyle, cascade, element.hover)}}` : "",
|
|
4702
4713
|
element.press && Object.keys(element.press).length ? `${selector}:active{${interactionDeclarations(element, byId, plan.id, rootStyle, cascade, element.press)}}` : ""
|
|
4703
4714
|
].filter(Boolean);
|
|
4704
4715
|
}).join("");
|
|
@@ -5131,6 +5142,10 @@ var SIZE_OPTIONS = [
|
|
|
5131
5142
|
{ label: "Fill", value: "fill" },
|
|
5132
5143
|
{ label: "Hug", value: "auto" }
|
|
5133
5144
|
];
|
|
5145
|
+
var HEIGHT_SIZE_OPTIONS = [
|
|
5146
|
+
...SIZE_OPTIONS,
|
|
5147
|
+
{ label: "Screen", value: "screen" }
|
|
5148
|
+
];
|
|
5134
5149
|
function SizeField({
|
|
5135
5150
|
axis,
|
|
5136
5151
|
mode,
|
|
@@ -5164,13 +5179,13 @@ function SizeField({
|
|
|
5164
5179
|
/* @__PURE__ */ jsx3("span", { className: "shrink-0 text-ed-faint", children: "px" })
|
|
5165
5180
|
] }) : (
|
|
5166
5181
|
// The layout owns this axis, so there is no number to type.
|
|
5167
|
-
/* @__PURE__ */ jsx3("span", { className: "flex-1 text-right text-ed-faint", children: mode === "fill" ? "Fill" : "Hug" })
|
|
5182
|
+
/* @__PURE__ */ jsx3("span", { className: "flex-1 text-right text-ed-faint", children: mode === "fill" ? "Fill" : mode === "screen" ? "100vh" : "Hug" })
|
|
5168
5183
|
) }),
|
|
5169
5184
|
/* @__PURE__ */ jsx3(
|
|
5170
5185
|
SelectShell,
|
|
5171
5186
|
{
|
|
5172
5187
|
value: mode,
|
|
5173
|
-
options: SIZE_OPTIONS,
|
|
5188
|
+
options: axis === "H" ? HEIGHT_SIZE_OPTIONS : SIZE_OPTIONS,
|
|
5174
5189
|
onChange: onMode,
|
|
5175
5190
|
ariaLabel: `${name} sizing mode`,
|
|
5176
5191
|
className: "w-[76px] shrink-0"
|
|
@@ -5466,6 +5481,7 @@ function DesignTab(ctx) {
|
|
|
5466
5481
|
/* @__PURE__ */ jsx4(SizeGroup, { ctx, ov }),
|
|
5467
5482
|
isContainer(element.type) && /* @__PURE__ */ jsx4(LayoutGroup, { ctx, ov }),
|
|
5468
5483
|
textual && /* @__PURE__ */ jsx4(TypographyGroup, { ctx, ov }),
|
|
5484
|
+
element.type === "Icon" && /* @__PURE__ */ jsx4(IconAppearanceGroup, { ctx, ov }),
|
|
5469
5485
|
/* @__PURE__ */ jsx4(FillGroup, { ctx, ov, defaultOpen: !textual }),
|
|
5470
5486
|
/* @__PURE__ */ jsx4(SpacingGroup, { ctx, ov }),
|
|
5471
5487
|
/* @__PURE__ */ jsx4(EffectsGroup, { ctx, ov }),
|
|
@@ -5473,6 +5489,20 @@ function DesignTab(ctx) {
|
|
|
5473
5489
|
/* @__PURE__ */ jsx4(MotionGroup, { ctx, ov })
|
|
5474
5490
|
] });
|
|
5475
5491
|
}
|
|
5492
|
+
function IconAppearanceGroup({ ctx, ov }) {
|
|
5493
|
+
const { style, onStyle } = ctx;
|
|
5494
|
+
return /* @__PURE__ */ jsx4(Group, { title: "Icon", children: ov(
|
|
5495
|
+
["color"],
|
|
5496
|
+
/* @__PURE__ */ jsx4(
|
|
5497
|
+
ColorInput,
|
|
5498
|
+
{
|
|
5499
|
+
label: "Colour",
|
|
5500
|
+
value: style.color,
|
|
5501
|
+
onChange: (color) => onStyle({ color })
|
|
5502
|
+
}
|
|
5503
|
+
)
|
|
5504
|
+
) });
|
|
5505
|
+
}
|
|
5476
5506
|
function SizeGroup({ ctx, ov }) {
|
|
5477
5507
|
const { style, parentLayout, onStyle, onCommitStart, onCommitEnd } = ctx;
|
|
5478
5508
|
return /* @__PURE__ */ jsxs3(Group, { title: "Size", children: [
|
|
@@ -6791,6 +6821,18 @@ function HoverTab({ element, style, onProps, onStyle }) {
|
|
|
6791
6821
|
}
|
|
6792
6822
|
) : null,
|
|
6793
6823
|
children: [
|
|
6824
|
+
element.parentId && /* @__PURE__ */ jsx4(
|
|
6825
|
+
Segmented,
|
|
6826
|
+
{
|
|
6827
|
+
label: "Trigger",
|
|
6828
|
+
value: element.hoverTrigger ?? "self",
|
|
6829
|
+
options: [
|
|
6830
|
+
{ label: "Self", value: "self" },
|
|
6831
|
+
{ label: "Parent", value: "parent" }
|
|
6832
|
+
],
|
|
6833
|
+
onChange: (hoverTrigger) => onProps({ hoverTrigger })
|
|
6834
|
+
}
|
|
6835
|
+
),
|
|
6794
6836
|
/* @__PURE__ */ jsx4(
|
|
6795
6837
|
ColorInput,
|
|
6796
6838
|
{
|
|
@@ -11262,6 +11304,7 @@ function Editor({
|
|
|
11262
11304
|
const [hoveredEffectIds, setHoveredEffectIds] = useState14(() => /* @__PURE__ */ new Set());
|
|
11263
11305
|
const [pressedEffectId, setPressedEffectId] = useState14(null);
|
|
11264
11306
|
const [effectsPreview, setEffectsPreview] = useState14(false);
|
|
11307
|
+
const [stickyPreview, setStickyPreview] = useState14(true);
|
|
11265
11308
|
const [previewVisibility, setPreviewVisibility] = useState14({});
|
|
11266
11309
|
const [marquee, setMarquee] = useState14(null);
|
|
11267
11310
|
const [codeComposerOpen, setCodeComposerOpen] = useState14(false);
|
|
@@ -12520,6 +12563,13 @@ function Editor({
|
|
|
12520
12563
|
const band = isBand(el.type, style, rootStyle);
|
|
12521
12564
|
const css = styleToCss(style, contextFor(el, frame.bp), el);
|
|
12522
12565
|
if (css.position === "fixed") css.position = "absolute";
|
|
12566
|
+
if (!stickyPreview && style.position === "sticky") {
|
|
12567
|
+
css.position = "relative";
|
|
12568
|
+
css.top = void 0;
|
|
12569
|
+
css.right = void 0;
|
|
12570
|
+
css.bottom = void 0;
|
|
12571
|
+
css.left = void 0;
|
|
12572
|
+
}
|
|
12523
12573
|
if (el.hover || el.press) css.transition = "transform .42s cubic-bezier(.16,1,.3,1), scale .42s cubic-bezier(.16,1,.3,1), rotate .42s cubic-bezier(.16,1,.3,1), background-color .32s ease, color .32s ease, border-color .32s ease, box-shadow .42s cubic-bezier(.16,1,.3,1), opacity .32s ease, filter .42s ease";
|
|
12524
12574
|
if (el.loop) css.animation = `pg-loop-${el.loop.type} ${el.loop.duration}ms ease-in-out infinite`;
|
|
12525
12575
|
css.cursor = el.locked ? "default" : isEditing ? "text" : "move";
|
|
@@ -12542,6 +12592,10 @@ function Editor({
|
|
|
12542
12592
|
el.type === "Request" ? canvasData[el.sourceId ?? ""]?.[0] : row,
|
|
12543
12593
|
keyPrefix
|
|
12544
12594
|
));
|
|
12595
|
+
const hoverEffectIds = [
|
|
12596
|
+
...el.hover && el.hoverTrigger !== "parent" ? [el.id] : [],
|
|
12597
|
+
...children.filter((child) => child.hover && child.hoverTrigger === "parent").map((child) => child.id)
|
|
12598
|
+
];
|
|
12545
12599
|
return (
|
|
12546
12600
|
// A canvas node is manipulated by pointer; the Layers panel is its keyboard equivalent.
|
|
12547
12601
|
// biome-ignore lint/a11y/noStaticElementInteractions: pointer-driven canvas node
|
|
@@ -12551,18 +12605,18 @@ function Editor({
|
|
|
12551
12605
|
"data-canvas-element": el.id,
|
|
12552
12606
|
style: split ? split.shell : css,
|
|
12553
12607
|
onMouseDown: (event) => handleElementMouseDown(event, interactionElement, frame.bp),
|
|
12554
|
-
onMouseEnter: () => effectsPreview &&
|
|
12555
|
-
if (current.has(
|
|
12608
|
+
onMouseEnter: () => effectsPreview && hoverEffectIds.length > 0 && setHoveredEffectIds((current) => {
|
|
12609
|
+
if (hoverEffectIds.every((id) => current.has(id))) return current;
|
|
12556
12610
|
const next = new Set(current);
|
|
12557
|
-
next.add(
|
|
12611
|
+
for (const id of hoverEffectIds) next.add(id);
|
|
12558
12612
|
return next;
|
|
12559
12613
|
}),
|
|
12560
12614
|
onMouseUp: () => effectsPreview && setPressedEffectId((id) => id === el.id ? null : id),
|
|
12561
12615
|
onMouseLeave: () => {
|
|
12562
12616
|
setHoveredEffectIds((current) => {
|
|
12563
|
-
if (!current.has(
|
|
12617
|
+
if (!hoverEffectIds.some((id) => current.has(id))) return current;
|
|
12564
12618
|
const next = new Set(current);
|
|
12565
|
-
next.delete(
|
|
12619
|
+
for (const id of hoverEffectIds) next.delete(id);
|
|
12566
12620
|
return next;
|
|
12567
12621
|
});
|
|
12568
12622
|
setPressedEffectId((id) => id === el.id ? null : id);
|
|
@@ -12626,7 +12680,7 @@ function Editor({
|
|
|
12626
12680
|
Math.round(style.widthMode === "fixed" ? style.w : 0) || "auto",
|
|
12627
12681
|
" \xD7",
|
|
12628
12682
|
" ",
|
|
12629
|
-
Math.round(style.heightMode === "fixed" ? style.h : 0) || "auto"
|
|
12683
|
+
style.heightMode === "screen" ? "100vh" : Math.round(style.heightMode === "fixed" ? style.h : 0) || "auto"
|
|
12630
12684
|
]
|
|
12631
12685
|
}
|
|
12632
12686
|
),
|
|
@@ -13446,6 +13500,17 @@ function Editor({
|
|
|
13446
13500
|
setPressedEffectId(null);
|
|
13447
13501
|
setPreviewVisibility({});
|
|
13448
13502
|
}, className: `rounded-md p-1.5 transition-colors ${effectsPreview ? "bg-ed-accent text-white" : "text-ed-faint hover:bg-ed-field hover:text-ed-text"}`, children: /* @__PURE__ */ jsx15(IconPlayerPlay5, { size: 14, stroke: 1.5 }) }),
|
|
13503
|
+
/* @__PURE__ */ jsx15(
|
|
13504
|
+
"button",
|
|
13505
|
+
{
|
|
13506
|
+
type: "button",
|
|
13507
|
+
"aria-pressed": stickyPreview,
|
|
13508
|
+
title: stickyPreview ? "Disable sticky positioning on the canvas" : "Preview sticky positioning on the canvas",
|
|
13509
|
+
onClick: () => setStickyPreview((value) => !value),
|
|
13510
|
+
className: `rounded-md p-1.5 transition-colors ${stickyPreview ? "bg-ed-accent text-white" : "text-ed-faint hover:bg-ed-field hover:text-ed-text"}`,
|
|
13511
|
+
children: stickyPreview ? /* @__PURE__ */ jsx15(IconPinFilled, { size: 14, stroke: 1.5 }) : /* @__PURE__ */ jsx15(IconPin, { size: 14, stroke: 1.5 })
|
|
13512
|
+
}
|
|
13513
|
+
),
|
|
13449
13514
|
/* @__PURE__ */ jsx15(
|
|
13450
13515
|
"button",
|
|
13451
13516
|
{
|