@vectoriox/iox-ui 4.14.1 → 4.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1788,6 +1788,608 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImpo
|
|
|
1788
1788
|
args: ['childrenHost', { read: ViewContainerRef, static: true }]
|
|
1789
1789
|
}] } });
|
|
1790
1790
|
|
|
1791
|
+
/**
|
|
1792
|
+
* Shared data-source resolving pattern — the SINGLE source of truth for how an IoxDataSource
|
|
1793
|
+
* becomes a backend fetch, consumed by BOTH the page builder (iox-cms-client bindings panel) and
|
|
1794
|
+
* the SSR client engine (iox-client-engine DataSourceResolverService). There must be no second
|
|
1795
|
+
* copy of this decision logic; only the transport differs between the two:
|
|
1796
|
+
*
|
|
1797
|
+
* builder → authenticated org-scoped controllers (admin session):
|
|
1798
|
+
* single by id → GET admin.../resources/:type/:id (ContentService.getContentById)
|
|
1799
|
+
* collection → GET admin.../resources/:type (ContentService.getContent)
|
|
1800
|
+
* engine → @Public() controllers (no auth, isPublished filter applied server-side):
|
|
1801
|
+
* single by id → GET api/:type/:id (UserResourcesController)
|
|
1802
|
+
* collection → GET api/:type?filters (UserResourcesController)
|
|
1803
|
+
*
|
|
1804
|
+
* The DECISION (by-id vs collection, which id, which filters, how to pick the single result) is
|
|
1805
|
+
* identical for both — that's what this module owns. Each app maps the returned {@link DataSourcePlan}
|
|
1806
|
+
* onto its own transport. A single item is ALWAYS fetched by its id as a path segment — never a
|
|
1807
|
+
* `?_id=` query — to match the builder's getContentById.
|
|
1808
|
+
*/
|
|
1809
|
+
/**
|
|
1810
|
+
* Match a page route template against an actual path and extract named params.
|
|
1811
|
+
* matchRouteParams('/project/:projectId', 'project/68abc') → { projectId: '68abc' }
|
|
1812
|
+
* Both sides are normalised (leading slash stripped) and split on '/'; values are URL-decoded.
|
|
1813
|
+
*/
|
|
1814
|
+
function matchRouteParams(template, actual) {
|
|
1815
|
+
const t = (template ?? '').replace(/^\/+/, '').split('/');
|
|
1816
|
+
const a = (actual ?? '').replace(/^\/+/, '').split('/');
|
|
1817
|
+
const params = {};
|
|
1818
|
+
for (let i = 0; i < t.length; i++) {
|
|
1819
|
+
const seg = t[i];
|
|
1820
|
+
if (seg && seg.startsWith(':'))
|
|
1821
|
+
params[seg.slice(1)] = decodeURIComponent(a[i] ?? '');
|
|
1822
|
+
}
|
|
1823
|
+
return params;
|
|
1824
|
+
}
|
|
1825
|
+
/**
|
|
1826
|
+
* Build the query filter for a data-source request from its filterByRouteParam / filterByQueryParams
|
|
1827
|
+
* config. `field` is filtered by the route-param value (falling back to `defaultId`); query filters
|
|
1828
|
+
* map each `field` to a query-param value (falling back to `defaultValue`), and a filter with NO
|
|
1829
|
+
* `queryParam` is a constant on `defaultValue`. Empty values are omitted so the source isn't
|
|
1830
|
+
* accidentally filtered to nothing.
|
|
1831
|
+
*/
|
|
1832
|
+
function buildDataSourceFilter(req, routeParams = {}, queryParams = {}) {
|
|
1833
|
+
const params = {};
|
|
1834
|
+
const byRoute = req?.filterByRouteParam;
|
|
1835
|
+
if (byRoute?.field && byRoute?.routeParam) {
|
|
1836
|
+
const value = routeParams[byRoute.routeParam] ?? byRoute.defaultId;
|
|
1837
|
+
if (value !== undefined && value !== null && value !== '')
|
|
1838
|
+
params[byRoute.field] = value;
|
|
1839
|
+
}
|
|
1840
|
+
for (const q of req?.filterByQueryParams ?? []) {
|
|
1841
|
+
if (!q?.field)
|
|
1842
|
+
continue;
|
|
1843
|
+
// No queryParam → a CONSTANT filter: defaultValue is the value, in the builder preview and
|
|
1844
|
+
// on the live site alike. With a queryParam the URL wins and defaultValue is the fallback.
|
|
1845
|
+
const value = q.queryParam ? (queryParams[q.queryParam] ?? q.defaultValue) : q.defaultValue;
|
|
1846
|
+
if (value !== undefined && value !== null && value !== '')
|
|
1847
|
+
params[q.field] = value;
|
|
1848
|
+
}
|
|
1849
|
+
return params;
|
|
1850
|
+
}
|
|
1851
|
+
/**
|
|
1852
|
+
* Mirrors the builder's getContentById decision. When a SINGLE source is filtered by the item id
|
|
1853
|
+
* (`_id`) via a route param, the item is fetched by PATH (`.../:type/:id`), not a `?_id=` query.
|
|
1854
|
+
* Returns that id (route-param value, else `defaultId`), or `null` when the source isn't a
|
|
1855
|
+
* single-by-id fetch — in which case the caller uses the collection endpoint + filters. The
|
|
1856
|
+
* path-based form only applies to the id field; a single source filtered by any other field still
|
|
1857
|
+
* goes through the collection endpoint.
|
|
1858
|
+
*/
|
|
1859
|
+
function resolveSingleItemId(req, routeParams = {}) {
|
|
1860
|
+
const byRoute = req?.filterByRouteParam;
|
|
1861
|
+
if (!byRoute?.routeParam)
|
|
1862
|
+
return null;
|
|
1863
|
+
if (byRoute.field && byRoute.field !== '_id')
|
|
1864
|
+
return null;
|
|
1865
|
+
const value = routeParams[byRoute.routeParam] ?? byRoute.defaultId;
|
|
1866
|
+
return value !== undefined && value !== null && value !== '' ? String(value) : null;
|
|
1867
|
+
}
|
|
1868
|
+
/**
|
|
1869
|
+
* Resolve an IoxDataSource into a transport-agnostic {@link DataSourcePlan}. This is the shared
|
|
1870
|
+
* decision both the builder and the SSR engine run; each then executes the plan with its own
|
|
1871
|
+
* HTTP layer. Single sources with a route-param id resolve to a `by-id` (path) fetch — exactly like
|
|
1872
|
+
* the builder's getContentById.
|
|
1873
|
+
*/
|
|
1874
|
+
function planDataSource(ds, routeParams = {}, queryParams = {}) {
|
|
1875
|
+
const mode = ds?.mode === 'single' ? 'single' : 'list';
|
|
1876
|
+
switch (ds?.type) {
|
|
1877
|
+
case 'cms':
|
|
1878
|
+
case 'shop': {
|
|
1879
|
+
const domain = ds.type;
|
|
1880
|
+
const req = domain === 'cms' ? ds.request?.cms : ds.request?.shop;
|
|
1881
|
+
const resource = req?.contentType;
|
|
1882
|
+
if (!resource)
|
|
1883
|
+
return { kind: 'none' };
|
|
1884
|
+
const id = mode === 'single' ? resolveSingleItemId(req, routeParams) : null;
|
|
1885
|
+
if (id)
|
|
1886
|
+
return { kind: 'by-id', domain, resource, id, mode };
|
|
1887
|
+
const filter = buildDataSourceFilter(req, routeParams, queryParams);
|
|
1888
|
+
const plan = { kind: 'collection', domain, resource, filter, mode };
|
|
1889
|
+
if (req?.limit)
|
|
1890
|
+
plan.limit = req.limit;
|
|
1891
|
+
return plan;
|
|
1892
|
+
}
|
|
1893
|
+
case 'external-api': {
|
|
1894
|
+
const endpoint = ds.request?.endpoint;
|
|
1895
|
+
return endpoint ? { kind: 'external', endpoint, mode } : { kind: 'none' };
|
|
1896
|
+
}
|
|
1897
|
+
case 'static':
|
|
1898
|
+
return { kind: 'static', data: ds.request?.data ?? null, mode };
|
|
1899
|
+
case 'derived': {
|
|
1900
|
+
const d = ds.request?.derived;
|
|
1901
|
+
return d?.from ? { kind: 'derived', from: d.from, path: d.path ?? '', mode } : { kind: 'none' };
|
|
1902
|
+
}
|
|
1903
|
+
default:
|
|
1904
|
+
return { kind: 'none' };
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
/**
|
|
1908
|
+
* Normalise a collection response into the shape a source expects: unwraps `{items}`/`{data}`
|
|
1909
|
+
* envelopes, then returns the first item for a `single` source or the whole array for a `list`.
|
|
1910
|
+
* A `by-id` response is already the item, so callers pass it through unchanged.
|
|
1911
|
+
*/
|
|
1912
|
+
function normalizeCollectionResult(mode, data) {
|
|
1913
|
+
const items = Array.isArray(data) ? data : (data?.items ?? data?.data ?? []);
|
|
1914
|
+
return mode === 'single' ? (items[0] ?? null) : items;
|
|
1915
|
+
}
|
|
1916
|
+
/** Navigate a dot/bracket path into an object, e.g. `gallery`, `details.images`, `variants[0].skus`.
|
|
1917
|
+
* Returns undefined if any segment is missing. Kept minimal + self-contained so it is shareable by
|
|
1918
|
+
* both the builder and the SSR engine (the two have divergent local `resolvePath` copies). */
|
|
1919
|
+
function getByPath(obj, path) {
|
|
1920
|
+
if (obj == null || !path)
|
|
1921
|
+
return obj;
|
|
1922
|
+
// Turn `a[0].b` into ['a','0','b'].
|
|
1923
|
+
const segments = path.replace(/\[(\w+)\]/g, '.$1').split('.').filter(Boolean);
|
|
1924
|
+
let cur = obj;
|
|
1925
|
+
for (const seg of segments) {
|
|
1926
|
+
if (cur == null)
|
|
1927
|
+
return undefined;
|
|
1928
|
+
cur = cur[seg];
|
|
1929
|
+
}
|
|
1930
|
+
return cur;
|
|
1931
|
+
}
|
|
1932
|
+
/**
|
|
1933
|
+
* The array a Repeater (or a repeating Slider) iterates over — the SHARED decision so the builder
|
|
1934
|
+
* canvas and the SSR engine agree (see data-source-resolving.md).
|
|
1935
|
+
* - No `sourcePath`: the resolved source IS the list. An array passes through; a lone object degrades
|
|
1936
|
+
* to a single-row list (back-compat with the old `fetchItems`); null/undefined → `[]`.
|
|
1937
|
+
* - With `sourcePath`: the source resolved to a SINGLE resource (e.g. a product); dive into the
|
|
1938
|
+
* nested array property (e.g. `gallery`). Non-array / missing → `[]`.
|
|
1939
|
+
*/
|
|
1940
|
+
/**
|
|
1941
|
+
* Fill in `derived` aliases in an already-resolved map. Given the data sources and a map of resolved
|
|
1942
|
+
* NON-derived values (by alias), compute each `type:'derived'` alias as `getByPath(parent, path)` in
|
|
1943
|
+
* dependency order (chains resolved by iterating until stable). Mutates + returns the map. Used by the
|
|
1944
|
+
* SSR engine after it resolves the root sources; the builder resolves derived lazily per alias.
|
|
1945
|
+
*/
|
|
1946
|
+
function resolveDerivedInto(dataSources, resolved) {
|
|
1947
|
+
const derived = (dataSources ?? []).filter(d => d?.type === 'derived' && !!d.alias);
|
|
1948
|
+
let changed = true, guard = 0;
|
|
1949
|
+
while (changed && guard++ < 25) {
|
|
1950
|
+
changed = false;
|
|
1951
|
+
for (const d of derived) {
|
|
1952
|
+
if (d.alias in resolved)
|
|
1953
|
+
continue;
|
|
1954
|
+
const from = d.request?.derived?.from;
|
|
1955
|
+
if (from && from in resolved) {
|
|
1956
|
+
resolved[d.alias] = getByPath(resolved[from], d.request.derived.path ?? '');
|
|
1957
|
+
changed = true;
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
return resolved;
|
|
1962
|
+
}
|
|
1963
|
+
function resolveRepeaterItems(resolved, sourcePath) {
|
|
1964
|
+
if (sourcePath) {
|
|
1965
|
+
const nested = getByPath(resolved, sourcePath);
|
|
1966
|
+
return Array.isArray(nested) ? nested : [];
|
|
1967
|
+
}
|
|
1968
|
+
if (Array.isArray(resolved))
|
|
1969
|
+
return resolved;
|
|
1970
|
+
return resolved == null ? [] : [resolved];
|
|
1971
|
+
}
|
|
1972
|
+
// ── Referenced data sources (shared: builder-save + SSR resolve) ─────────────────────────────────
|
|
1973
|
+
// Which data-source aliases a node tree uses, so a consumer resolves ONLY what a page/global needs
|
|
1974
|
+
// (not the whole org library). Used by cms-client (persist globals' sources) and the SSR engine
|
|
1975
|
+
// (resolve the referenced subset per page). See org-data-sources-plan.md.
|
|
1976
|
+
/** Aliases referenced anywhere in a node tree: a repeater/slider `source` (a `bindings` entry with
|
|
1977
|
+
* `prop === 'source'`, or `props.source` for the slider) plus every binding's `source`. */
|
|
1978
|
+
function collectReferencedAliases(nodes, acc = new Set()) {
|
|
1979
|
+
for (const n of nodes ?? []) {
|
|
1980
|
+
for (const b of n?.bindings ?? []) {
|
|
1981
|
+
if (b?.source)
|
|
1982
|
+
acc.add(b.source);
|
|
1983
|
+
}
|
|
1984
|
+
if (n?.props?.source)
|
|
1985
|
+
acc.add(n.props.source);
|
|
1986
|
+
if (n?.children?.length)
|
|
1987
|
+
collectReferencedAliases(n.children, acc);
|
|
1988
|
+
}
|
|
1989
|
+
return acc;
|
|
1990
|
+
}
|
|
1991
|
+
/** The subset of `all` whose `alias` is referenced by `nodes` (shallow — direct references only). */
|
|
1992
|
+
function referencedDataSources(nodes, all) {
|
|
1993
|
+
const aliases = collectReferencedAliases(nodes);
|
|
1994
|
+
return (all ?? []).filter(ds => aliases.has(ds?.alias));
|
|
1995
|
+
}
|
|
1996
|
+
/** Like `referencedDataSources`, but also pulls in a referenced source's DEPENDENCIES (transitively),
|
|
1997
|
+
* none of which the layout names directly:
|
|
1998
|
+
* - a `derived` alias needs its `request.derived.from` parent;
|
|
1999
|
+
* - a `relative` (route-context) alias needs its `request.relative.subjectFrom` (the current item)
|
|
2000
|
+
* and `request.relative.from` (the collection it navigates).
|
|
2001
|
+
* Use this when RESOLVING (the engine) so derived views and siblings/related don't resolve to nothing. */
|
|
2002
|
+
function referencedDataSourcesDeep(nodes, all) {
|
|
2003
|
+
const byAlias = new Map((all ?? []).map(d => [d?.alias, d]));
|
|
2004
|
+
const wanted = collectReferencedAliases(nodes);
|
|
2005
|
+
const queue = [...wanted];
|
|
2006
|
+
const enqueue = (alias) => {
|
|
2007
|
+
if (alias && !wanted.has(alias)) {
|
|
2008
|
+
wanted.add(alias);
|
|
2009
|
+
queue.push(alias);
|
|
2010
|
+
}
|
|
2011
|
+
};
|
|
2012
|
+
while (queue.length) {
|
|
2013
|
+
const ds = byAlias.get(queue.pop());
|
|
2014
|
+
enqueue(ds?.request?.derived?.from);
|
|
2015
|
+
enqueue(ds?.request?.relative?.subjectFrom);
|
|
2016
|
+
enqueue(ds?.request?.relative?.from);
|
|
2017
|
+
}
|
|
2018
|
+
return (all ?? []).filter(d => wanted.has(d?.alias));
|
|
2019
|
+
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Turn a resolved {@link DataSourcePlan} into the concrete public HTTP request (url + params) — the
|
|
2022
|
+
* SINGLE place that decides the URL shape, shared by the builder binding preview and the SSR engine
|
|
2023
|
+
* so both hit the SAME public endpoints (no admin, no duplication). Returns `null` for `static` /
|
|
2024
|
+
* `none` plans (no HTTP call). A single item is a PATH segment (`/:resource/:id`), never `?_id=`.
|
|
2025
|
+
*/
|
|
2026
|
+
function dataSourcePlanToRequest(plan, endpoints) {
|
|
2027
|
+
const baseFor = (domain) => (domain === 'shop' ? endpoints.shopBase : endpoints.cmsBase);
|
|
2028
|
+
switch (plan.kind) {
|
|
2029
|
+
case 'by-id':
|
|
2030
|
+
return { url: `${baseFor(plan.domain)}${plan.resource}/${encodeURIComponent(plan.id)}`, params: {} };
|
|
2031
|
+
case 'collection': {
|
|
2032
|
+
const params = { ...plan.filter };
|
|
2033
|
+
if (plan.limit)
|
|
2034
|
+
params['limit'] = plan.limit;
|
|
2035
|
+
return { url: `${baseFor(plan.domain)}${plan.resource}`, params };
|
|
2036
|
+
}
|
|
2037
|
+
case 'external':
|
|
2038
|
+
return { url: plan.endpoint, params: {} };
|
|
2039
|
+
default:
|
|
2040
|
+
return null;
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
/**
|
|
2045
|
+
* Gallery — data model + the SHARED "what are the image URLs" resolver.
|
|
2046
|
+
*
|
|
2047
|
+
* A Gallery is populated one of two ways (see gallery-component-plan.md):
|
|
2048
|
+
* - **Manual**: an ordered `GalleryImage[]` trait. Each item is a raw/filebox URL OR a per-item
|
|
2049
|
+
* binding path resolved against the item/route context.
|
|
2050
|
+
* - **Bound**: the whole gallery repeats over a data source (a collection, or an array property
|
|
2051
|
+
* inside one resource via `sourcePath`) — the items come from the shared `resolveRepeaterItems`,
|
|
2052
|
+
* and one field of each record (`imageField`) is the URL.
|
|
2053
|
+
*
|
|
2054
|
+
* `resolveGalleryUrls` is the SINGLE place both the builder canvas and the SSR/runtime engine turn a
|
|
2055
|
+
* node's config into the final `string[]` the crossfade loop consumes — so both sides show the same
|
|
2056
|
+
* images. It is pure and transport-agnostic; never fork it. It reuses `getByPath` (the same path
|
|
2057
|
+
* reader used by data-source resolving) so binding paths behave identically everywhere.
|
|
2058
|
+
*/
|
|
2059
|
+
/**
|
|
2060
|
+
* Resolve a gallery's configured images to the flat, display-ready `string[]` the loop renders.
|
|
2061
|
+
* Pure. Empty/whitespace URLs are dropped so a half-filled manual list never yields blank frames.
|
|
2062
|
+
*
|
|
2063
|
+
* @param images manual `GalleryImage[]` (manual mode). Ignored when `bound` is provided.
|
|
2064
|
+
* @param bound bound-mode records + image field. When present, takes precedence over `images`.
|
|
2065
|
+
* @param ctx render context for per-item bindings (item/route data). Defaults to `{}`.
|
|
2066
|
+
*/
|
|
2067
|
+
function resolveGalleryUrls(images, bound, ctx = {}) {
|
|
2068
|
+
if (bound) {
|
|
2069
|
+
const field = bound.imageField;
|
|
2070
|
+
return (bound.items ?? [])
|
|
2071
|
+
.map(item => (field ? getByPath(item, field) : item))
|
|
2072
|
+
.map(coerceUrl)
|
|
2073
|
+
.filter((u) => !!u);
|
|
2074
|
+
}
|
|
2075
|
+
return (images ?? [])
|
|
2076
|
+
.map(img => (img?.binding ? getByPath(ctx, img.binding) : img?.url))
|
|
2077
|
+
.map(coerceUrl)
|
|
2078
|
+
.filter((u) => !!u);
|
|
2079
|
+
}
|
|
2080
|
+
/** A resolved value becomes a URL only if it's a non-empty string; everything else drops out. */
|
|
2081
|
+
function coerceUrl(value) {
|
|
2082
|
+
if (typeof value !== 'string')
|
|
2083
|
+
return null;
|
|
2084
|
+
const trimmed = value.trim();
|
|
2085
|
+
return trimmed ? trimmed : null;
|
|
2086
|
+
}
|
|
2087
|
+
/**
|
|
2088
|
+
* Parse a pasted block of URLs (newline- and/or comma-separated) into `GalleryImage[]`. Used by the
|
|
2089
|
+
* MediaList trait editor's "paste URL list" affordance. Pure so it can be unit-tested + reused.
|
|
2090
|
+
*/
|
|
2091
|
+
function parseUrlList(text) {
|
|
2092
|
+
if (!text)
|
|
2093
|
+
return [];
|
|
2094
|
+
return text
|
|
2095
|
+
.split(/[\n,]+/)
|
|
2096
|
+
.map(s => s.trim())
|
|
2097
|
+
.filter(Boolean)
|
|
2098
|
+
.map(url => ({ url }));
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
/**
|
|
2102
|
+
* Gallery effect registry — the SHARED, effect-agnostic description of how each gallery slide looks
|
|
2103
|
+
* and animates in a given crossfade state. Modeled on `binding-pipes.ts` / `PAGE_TRANSITION_PRESETS`:
|
|
2104
|
+
* a descriptor map + a resolve fn, all pure, consumed by BOTH the builder twin and the engine twin so
|
|
2105
|
+
* the fade looks identical on the canvas and the live site.
|
|
2106
|
+
*
|
|
2107
|
+
* v1 ships only `fade` (the look ported from `sites/interior-design` fade-gallery). Adding `slide`,
|
|
2108
|
+
* `zoom`, `kenburns`, … later is a new entry here + its CSS — NO component refactor, because the
|
|
2109
|
+
* component only ever asks `resolveGalleryEffect(id).buildSlideStyle(state)` for per-slide inline CSS.
|
|
2110
|
+
*
|
|
2111
|
+
* See iox-ai-guidance/architecture/builder/gallery-component-plan.md.
|
|
2112
|
+
*/
|
|
2113
|
+
/** The ken-burns zoom baked into the reference fade look (scale 1 → 1.05 over show+fade). */
|
|
2114
|
+
const FADE_ZOOM_EXTRA_MS = 1000;
|
|
2115
|
+
const fadeEffect = {
|
|
2116
|
+
id: 'fade',
|
|
2117
|
+
label: 'Fade',
|
|
2118
|
+
defaults: { showMs: 4000, fadeMs: 1000 },
|
|
2119
|
+
buildSlideStyle(state) {
|
|
2120
|
+
const { active, fadingOut, firstCycleActive, durations } = state;
|
|
2121
|
+
const zoomMs = durations.showMs + durations.fadeMs + FADE_ZOOM_EXTRA_MS;
|
|
2122
|
+
// Before the first cycle activates, everything is flat + instant so there is no entry flash.
|
|
2123
|
+
if (!firstCycleActive) {
|
|
2124
|
+
return { opacity: '0', transform: 'scale(1)', transition: 'opacity 0ms, transform 0ms' };
|
|
2125
|
+
}
|
|
2126
|
+
const visible = active && !fadingOut;
|
|
2127
|
+
const engaged = active || fadingOut; // current or outgoing slide is zoomed-in
|
|
2128
|
+
return {
|
|
2129
|
+
opacity: visible ? '1' : '0',
|
|
2130
|
+
transform: engaged ? 'scale(1.05)' : 'scale(1)',
|
|
2131
|
+
transition: `opacity ${durations.fadeMs}ms cubic-bezier(0.77, 0, 0.175, 1), ` +
|
|
2132
|
+
`transform ${zoomMs}ms linear`,
|
|
2133
|
+
};
|
|
2134
|
+
},
|
|
2135
|
+
};
|
|
2136
|
+
/** The registry — one entry per effect. Keyed by id; unknown ids fall back to `fade`. */
|
|
2137
|
+
const GALLERY_EFFECTS = {
|
|
2138
|
+
fade: fadeEffect,
|
|
2139
|
+
};
|
|
2140
|
+
/** Resolve an effect id to its descriptor, defaulting to `fade` (forward-compatible with new ids). */
|
|
2141
|
+
function resolveGalleryEffect(id) {
|
|
2142
|
+
return (id && GALLERY_EFFECTS[id]) || GALLERY_EFFECTS['fade'];
|
|
2143
|
+
}
|
|
2144
|
+
const GALLERY_EFFECT_DESCRIPTORS = Object.values(GALLERY_EFFECTS).map(e => ({ id: e.id, label: e.label }));
|
|
2145
|
+
|
|
2146
|
+
/**
|
|
2147
|
+
* Gallery crossfade loop — the SHARED, framework-free state machine that drives which image is
|
|
2148
|
+
* visible over time. Ported from the reference fade-gallery's `scheduleCycle` / `tryStartLoop` /
|
|
2149
|
+
* first-cycle-activation logic, with Angular specifics removed so BOTH the builder twin and the
|
|
2150
|
+
* engine/runtime twin (`iox-gallery`) run ONE implementation — never a forked copy that drifts.
|
|
2151
|
+
*
|
|
2152
|
+
* The loop owns only *timing + index* state and emits it through `onChange`; the component maps that
|
|
2153
|
+
* state to CSS via the effect registry (`gallery-effects.ts`). Timers are injected so the loop is
|
|
2154
|
+
* deterministic under fake timers in tests and platform-guardable in the engine.
|
|
2155
|
+
*
|
|
2156
|
+
* See iox-ai-guidance/architecture/builder/gallery-component-plan.md.
|
|
2157
|
+
*/
|
|
2158
|
+
/** Default scheduler over the platform's `setTimeout` / `requestAnimationFrame` (with a timer fallback). */
|
|
2159
|
+
const DEFAULT_GALLERY_SCHEDULER = {
|
|
2160
|
+
setTimer: (fn, ms) => setTimeout(fn, ms),
|
|
2161
|
+
clearTimer: h => clearTimeout(h),
|
|
2162
|
+
nextFrame: fn => typeof requestAnimationFrame === 'function' ? requestAnimationFrame(fn) : setTimeout(fn, 16),
|
|
2163
|
+
cancelFrame: h => {
|
|
2164
|
+
if (typeof cancelAnimationFrame === 'function')
|
|
2165
|
+
cancelAnimationFrame(h);
|
|
2166
|
+
else
|
|
2167
|
+
clearTimeout(h);
|
|
2168
|
+
},
|
|
2169
|
+
};
|
|
2170
|
+
class GalleryLoop {
|
|
2171
|
+
constructor(opts) {
|
|
2172
|
+
this.count = 0;
|
|
2173
|
+
this.currentIndex = 0;
|
|
2174
|
+
this.fadingOutIndex = null;
|
|
2175
|
+
this.firstCycleActive = false;
|
|
2176
|
+
this.cycleTimer = null;
|
|
2177
|
+
this.fadeTimer = null;
|
|
2178
|
+
this.frameHandle = null;
|
|
2179
|
+
/** Bumped on every (re)start so a stale timer callback from a previous run is ignored. */
|
|
2180
|
+
this.runToken = 0;
|
|
2181
|
+
this.running = false;
|
|
2182
|
+
this.scheduler = opts.scheduler ?? DEFAULT_GALLERY_SCHEDULER;
|
|
2183
|
+
this.showMs = opts.showMs;
|
|
2184
|
+
this.fadeMs = opts.fadeMs;
|
|
2185
|
+
this.onChange = opts.onChange;
|
|
2186
|
+
}
|
|
2187
|
+
get state() {
|
|
2188
|
+
return {
|
|
2189
|
+
currentIndex: this.currentIndex,
|
|
2190
|
+
fadingOutIndex: this.fadingOutIndex,
|
|
2191
|
+
firstCycleActive: this.firstCycleActive,
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
/** Start (or restart) the loop for `count` images. A count < 2 shows the single image without cycling. */
|
|
2195
|
+
start(count) {
|
|
2196
|
+
this.stop();
|
|
2197
|
+
this.count = Math.max(0, count | 0);
|
|
2198
|
+
this.currentIndex = 0;
|
|
2199
|
+
this.fadingOutIndex = null;
|
|
2200
|
+
this.firstCycleActive = false;
|
|
2201
|
+
if (this.count === 0) {
|
|
2202
|
+
this.emit();
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
2205
|
+
this.running = true;
|
|
2206
|
+
const token = ++this.runToken;
|
|
2207
|
+
// Activate on the next frame so the first paint is flat (no entry-transition flash), then
|
|
2208
|
+
// begin cycling only when there is more than one image to advance through.
|
|
2209
|
+
this.frameHandle = this.scheduler.nextFrame(() => {
|
|
2210
|
+
if (token !== this.runToken || !this.running)
|
|
2211
|
+
return;
|
|
2212
|
+
this.firstCycleActive = true;
|
|
2213
|
+
this.emit();
|
|
2214
|
+
if (this.count > 1)
|
|
2215
|
+
this.scheduleCycle(token);
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2218
|
+
/** Update timing live. Takes effect on the next scheduled cycle; does not restart the loop. */
|
|
2219
|
+
setDurations(showMs, fadeMs) {
|
|
2220
|
+
this.showMs = showMs;
|
|
2221
|
+
this.fadeMs = fadeMs;
|
|
2222
|
+
}
|
|
2223
|
+
/** Stop and clear all timers. Idempotent. State is retained (call `start` to reset). */
|
|
2224
|
+
stop() {
|
|
2225
|
+
this.running = false;
|
|
2226
|
+
this.runToken++;
|
|
2227
|
+
if (this.cycleTimer != null) {
|
|
2228
|
+
this.scheduler.clearTimer(this.cycleTimer);
|
|
2229
|
+
this.cycleTimer = null;
|
|
2230
|
+
}
|
|
2231
|
+
if (this.fadeTimer != null) {
|
|
2232
|
+
this.scheduler.clearTimer(this.fadeTimer);
|
|
2233
|
+
this.fadeTimer = null;
|
|
2234
|
+
}
|
|
2235
|
+
if (this.frameHandle != null) {
|
|
2236
|
+
this.scheduler.cancelFrame(this.frameHandle);
|
|
2237
|
+
this.frameHandle = null;
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
scheduleCycle(token) {
|
|
2241
|
+
if (token !== this.runToken || !this.running || this.count < 2)
|
|
2242
|
+
return;
|
|
2243
|
+
this.cycleTimer = this.scheduler.setTimer(() => {
|
|
2244
|
+
if (token !== this.runToken || !this.running)
|
|
2245
|
+
return;
|
|
2246
|
+
this.fadingOutIndex = this.currentIndex;
|
|
2247
|
+
this.currentIndex = (this.currentIndex + 1) % this.count;
|
|
2248
|
+
this.emit();
|
|
2249
|
+
this.fadeTimer = this.scheduler.setTimer(() => {
|
|
2250
|
+
if (token !== this.runToken || !this.running)
|
|
2251
|
+
return;
|
|
2252
|
+
this.fadingOutIndex = null;
|
|
2253
|
+
this.emit();
|
|
2254
|
+
this.scheduleCycle(token);
|
|
2255
|
+
}, this.fadeMs);
|
|
2256
|
+
}, this.showMs);
|
|
2257
|
+
}
|
|
2258
|
+
emit() {
|
|
2259
|
+
this.onChange(this.state);
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
/**
|
|
2264
|
+
* `iox-gallery` — the engine/runtime twin of the Gallery builder component (the builder twin
|
|
2265
|
+
* `app-builder-gallery` arrives in Phase 2). It runs the SHARED crossfade loop + effect registry from
|
|
2266
|
+
* `@vectoriox/iox-ui`, so the fade behaves identically on the canvas and the live site — no forked
|
|
2267
|
+
* animation logic. See iox-ai-guidance/architecture/builder/gallery-component-plan.md.
|
|
2268
|
+
*
|
|
2269
|
+
* Population: v1 drives the loop from the `images` input (manual mode). Data-source binding
|
|
2270
|
+
* (`source`/`sourcePath`) is Phase 3 — this component already resolves through `resolveGalleryUrls`,
|
|
2271
|
+
* so that only adds a bound-source input, not new render logic.
|
|
2272
|
+
*
|
|
2273
|
+
* SSR / no-JS: on the server the loop never starts, so the **first image renders visible** as a static
|
|
2274
|
+
* frame (SEO + no-JS baseline). In the browser `ngAfterViewInit` starts the loop, which begins on the
|
|
2275
|
+
* same index 0 — so the static frame and the first live frame match and there is no hydration flash.
|
|
2276
|
+
*/
|
|
2277
|
+
class ClientGalleryComponent {
|
|
2278
|
+
constructor(platformId, cdr) {
|
|
2279
|
+
this.cdr = cdr;
|
|
2280
|
+
this.nodeId = '';
|
|
2281
|
+
/** Manual images — `GalleryImage[]` or a plain `string[]` of URLs (normalised on set). */
|
|
2282
|
+
this.images = [];
|
|
2283
|
+
this.effect = 'fade';
|
|
2284
|
+
this.showDuration = 4000;
|
|
2285
|
+
this.fadeDuration = 1000;
|
|
2286
|
+
this.autoStart = true;
|
|
2287
|
+
/** Layout mode — only `'stack'` is rendered in v1 (`'grid'` scaffolded for later). */
|
|
2288
|
+
this.layout = 'stack';
|
|
2289
|
+
/** The flat, display-ready URLs the loop cycles. Recomputed when `images` changes. */
|
|
2290
|
+
this.urls = [];
|
|
2291
|
+
this.currentIndex = 0;
|
|
2292
|
+
this.fadingOutIndex = null;
|
|
2293
|
+
this.started = false;
|
|
2294
|
+
this.loop = null;
|
|
2295
|
+
this.isBrowser = isPlatformBrowser(platformId);
|
|
2296
|
+
}
|
|
2297
|
+
ngOnChanges(changes) {
|
|
2298
|
+
if (changes['images']) {
|
|
2299
|
+
this.urls = resolveGalleryUrls(this.normalizeImages(), null);
|
|
2300
|
+
}
|
|
2301
|
+
if (this.loop && (changes['showDuration'] || changes['fadeDuration'])) {
|
|
2302
|
+
this.loop.setDurations(this.showDuration, this.fadeDuration);
|
|
2303
|
+
}
|
|
2304
|
+
// If the image set changed after the loop was already running, restart it on the new count.
|
|
2305
|
+
if (this.loop && changes['images'] && !changes['images'].firstChange) {
|
|
2306
|
+
this.startLoop();
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
ngAfterViewInit() {
|
|
2310
|
+
if (this.isBrowser && this.autoStart) {
|
|
2311
|
+
this.startLoop();
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
ngOnDestroy() {
|
|
2315
|
+
this.loop?.stop();
|
|
2316
|
+
this.loop = null;
|
|
2317
|
+
}
|
|
2318
|
+
/** Per-slide inline style. Before the loop activates (server + no-JS + pre-first-frame) the first
|
|
2319
|
+
* image is the visible static frame; once running, the effect owns each slide's look. */
|
|
2320
|
+
slideStyle(i) {
|
|
2321
|
+
if (!this.started) {
|
|
2322
|
+
return i === 0
|
|
2323
|
+
? { opacity: '1', transform: 'scale(1)', transition: 'none' }
|
|
2324
|
+
: { opacity: '0', transform: 'scale(1)', transition: 'none' };
|
|
2325
|
+
}
|
|
2326
|
+
return resolveGalleryEffect(this.effect).buildSlideStyle({
|
|
2327
|
+
active: i === this.currentIndex,
|
|
2328
|
+
fadingOut: i === this.fadingOutIndex,
|
|
2329
|
+
firstCycleActive: true,
|
|
2330
|
+
durations: { showMs: this.showDuration, fadeMs: this.fadeDuration },
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
2333
|
+
startLoop() {
|
|
2334
|
+
if (!this.isBrowser)
|
|
2335
|
+
return;
|
|
2336
|
+
this.loop?.stop();
|
|
2337
|
+
this.loop = new GalleryLoop({
|
|
2338
|
+
showMs: this.showDuration,
|
|
2339
|
+
fadeMs: this.fadeDuration,
|
|
2340
|
+
onChange: state => {
|
|
2341
|
+
this.currentIndex = state.currentIndex;
|
|
2342
|
+
this.fadingOutIndex = state.fadingOutIndex;
|
|
2343
|
+
this.started = state.firstCycleActive;
|
|
2344
|
+
this.cdr.markForCheck();
|
|
2345
|
+
},
|
|
2346
|
+
});
|
|
2347
|
+
this.loop.start(this.urls.length);
|
|
2348
|
+
}
|
|
2349
|
+
normalizeImages() {
|
|
2350
|
+
return (this.images ?? []).map(img => (typeof img === 'string' ? { url: img } : img));
|
|
2351
|
+
}
|
|
2352
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: ClientGalleryComponent, deps: [{ token: PLATFORM_ID }, { token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
2353
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.11", type: ClientGalleryComponent, isStandalone: false, selector: "iox-gallery", inputs: { nodeId: "nodeId", images: "images", effect: "effect", showDuration: "showDuration", fadeDuration: "fadeDuration", autoStart: "autoStart", layout: "layout" }, usesOnChanges: true, ngImport: i0, template: `
|
|
2354
|
+
<div class="iox-gallery" [class]="'iox-node-' + nodeId">
|
|
2355
|
+
<div *ngFor="let url of urls; index as i"
|
|
2356
|
+
class="iox-gallery__img"
|
|
2357
|
+
[style.background-image]="'url(' + url + ')'"
|
|
2358
|
+
[ngStyle]="slideStyle(i)"></div>
|
|
2359
|
+
<div class="iox-gallery__overlay"></div>
|
|
2360
|
+
</div>
|
|
2361
|
+
`, isInline: true, styles: [":host{display:block;width:100%;height:100%}.iox-gallery{position:relative;width:100%;height:100%;overflow:hidden;box-sizing:border-box}.iox-gallery__img{position:absolute;inset:0;background-size:cover;background-position:center;pointer-events:none;opacity:0;filter:brightness(1.35);-webkit-mask-image:radial-gradient(circle at 50% 50%,rgba(0,0,0,1) 80%,rgba(0,0,0,.1) 95%,rgba(0,0,0,0) 100%);mask-image:radial-gradient(circle at 50% 50%,#000 80%,#0000001a 95%,#0000)}.iox-gallery__overlay{position:absolute;inset:0;pointer-events:none;z-index:3;background:linear-gradient(to bottom,#00000080,#00000026 20% 80%,#00000080)}\n"], dependencies: [{ kind: "directive", type: i1$2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$2.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }] }); }
|
|
2362
|
+
}
|
|
2363
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: ClientGalleryComponent, decorators: [{
|
|
2364
|
+
type: Component,
|
|
2365
|
+
args: [{ selector: 'iox-gallery', template: `
|
|
2366
|
+
<div class="iox-gallery" [class]="'iox-node-' + nodeId">
|
|
2367
|
+
<div *ngFor="let url of urls; index as i"
|
|
2368
|
+
class="iox-gallery__img"
|
|
2369
|
+
[style.background-image]="'url(' + url + ')'"
|
|
2370
|
+
[ngStyle]="slideStyle(i)"></div>
|
|
2371
|
+
<div class="iox-gallery__overlay"></div>
|
|
2372
|
+
</div>
|
|
2373
|
+
`, standalone: false, styles: [":host{display:block;width:100%;height:100%}.iox-gallery{position:relative;width:100%;height:100%;overflow:hidden;box-sizing:border-box}.iox-gallery__img{position:absolute;inset:0;background-size:cover;background-position:center;pointer-events:none;opacity:0;filter:brightness(1.35);-webkit-mask-image:radial-gradient(circle at 50% 50%,rgba(0,0,0,1) 80%,rgba(0,0,0,.1) 95%,rgba(0,0,0,0) 100%);mask-image:radial-gradient(circle at 50% 50%,#000 80%,#0000001a 95%,#0000)}.iox-gallery__overlay{position:absolute;inset:0;pointer-events:none;z-index:3;background:linear-gradient(to bottom,#00000080,#00000026 20% 80%,#00000080)}\n"] }]
|
|
2374
|
+
}], ctorParameters: () => [{ type: undefined, decorators: [{
|
|
2375
|
+
type: Inject,
|
|
2376
|
+
args: [PLATFORM_ID]
|
|
2377
|
+
}] }, { type: i0.ChangeDetectorRef }], propDecorators: { nodeId: [{
|
|
2378
|
+
type: Input
|
|
2379
|
+
}], images: [{
|
|
2380
|
+
type: Input
|
|
2381
|
+
}], effect: [{
|
|
2382
|
+
type: Input
|
|
2383
|
+
}], showDuration: [{
|
|
2384
|
+
type: Input
|
|
2385
|
+
}], fadeDuration: [{
|
|
2386
|
+
type: Input
|
|
2387
|
+
}], autoStart: [{
|
|
2388
|
+
type: Input
|
|
2389
|
+
}], layout: [{
|
|
2390
|
+
type: Input
|
|
2391
|
+
}] } });
|
|
2392
|
+
|
|
1791
2393
|
const COMPONENTS = [
|
|
1792
2394
|
BuilderImageComponent,
|
|
1793
2395
|
BuilderSpacerComponent,
|
|
@@ -1807,6 +2409,7 @@ const COMPONENTS = [
|
|
|
1807
2409
|
ClientSliderSlideComponent,
|
|
1808
2410
|
ClientListComponent,
|
|
1809
2411
|
ClientListItemComponent,
|
|
2412
|
+
ClientGalleryComponent,
|
|
1810
2413
|
];
|
|
1811
2414
|
class IoxBuilderComponentsModule {
|
|
1812
2415
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: IoxBuilderComponentsModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
|
|
@@ -1827,7 +2430,8 @@ class IoxBuilderComponentsModule {
|
|
|
1827
2430
|
ClientSliderContainerComponent,
|
|
1828
2431
|
ClientSliderSlideComponent,
|
|
1829
2432
|
ClientListComponent,
|
|
1830
|
-
ClientListItemComponent
|
|
2433
|
+
ClientListItemComponent,
|
|
2434
|
+
ClientGalleryComponent], imports: [CommonModule], exports: [BuilderImageComponent,
|
|
1831
2435
|
BuilderSpacerComponent,
|
|
1832
2436
|
CardComponent,
|
|
1833
2437
|
BuilderDividerComponent,
|
|
@@ -1844,7 +2448,8 @@ class IoxBuilderComponentsModule {
|
|
|
1844
2448
|
ClientSliderContainerComponent,
|
|
1845
2449
|
ClientSliderSlideComponent,
|
|
1846
2450
|
ClientListComponent,
|
|
1847
|
-
ClientListItemComponent
|
|
2451
|
+
ClientListItemComponent,
|
|
2452
|
+
ClientGalleryComponent] }); }
|
|
1848
2453
|
static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: IoxBuilderComponentsModule, imports: [CommonModule] }); }
|
|
1849
2454
|
}
|
|
1850
2455
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImport: i0, type: IoxBuilderComponentsModule, decorators: [{
|
|
@@ -1891,6 +2496,7 @@ class IoxComponentRegistryService {
|
|
|
1891
2496
|
['SliderSlide', ClientSliderSlideComponent],
|
|
1892
2497
|
['List', ClientListComponent],
|
|
1893
2498
|
['ListItem', ClientListItemComponent],
|
|
2499
|
+
['Gallery', ClientGalleryComponent],
|
|
1894
2500
|
]);
|
|
1895
2501
|
}
|
|
1896
2502
|
getComponent(type) {
|
|
@@ -2105,381 +2711,128 @@ function renderDefaultDeclarations(type, styleProps, hasHoverState) {
|
|
|
2105
2711
|
// parent. Phosphor forces `display:inline-flex` on its own class; generated (fantasticon) fonts
|
|
2106
2712
|
// don't, so the engine's plain `<i class="bi-x iox-node-…">` would fall back to inline and the
|
|
2107
2713
|
// glyph would overflow. Emit it here (guarded so a user-set display still wins) — mirrors the
|
|
2108
|
-
// builder Icon component's `:host{display:inline-flex}`.
|
|
2109
|
-
if (type === 'Icon' && sp['display'] === undefined) {
|
|
2110
|
-
out.push('display: inline-flex', 'align-items: center', 'justify-content: center');
|
|
2111
|
-
}
|
|
2112
|
-
return out;
|
|
2113
|
-
}
|
|
2114
|
-
|
|
2115
|
-
// Virtual trait → composed CSS property helpers.
|
|
2116
|
-
//
|
|
2117
|
-
// Several style traits are "virtual" — they don't map 1:1 to a CSS property but
|
|
2118
|
-
// are components of a multi-function CSS value. composeVirtualTraits() strips
|
|
2119
|
-
// these virtual keys from the style map and replaces them with the correctly
|
|
2120
|
-
// composed CSS properties (filter, backdrop-filter, transform, transition).
|
|
2121
|
-
//
|
|
2122
|
-
// SHARED (iox-ui): consumed by BOTH the builder (render.directive + style panel)
|
|
2123
|
-
// and the SSR engine (LayoutRendererService). It MUST live here, not in iox-builder,
|
|
2124
|
-
// because the engine does not depend on iox-builder — without it the engine emitted
|
|
2125
|
-
// the raw virtual keys (`translate-x`, `filter-blur`, `scale-x`…) as invalid CSS.
|
|
2126
|
-
// Run this BEFORE compileDeclarations so the CSS output is identical on both sides.
|
|
2127
|
-
// ─── Filter ──────────────────────────────────────────────────────────────────
|
|
2128
|
-
const FILTER_FUNS = [
|
|
2129
|
-
['filterBlur', 'blur', '0px'],
|
|
2130
|
-
['filterBrightness', 'brightness', '1'],
|
|
2131
|
-
['filterContrast', 'contrast', '1'],
|
|
2132
|
-
['filterGrayscale', 'grayscale', '0'],
|
|
2133
|
-
['filterSaturate', 'saturate', '1'],
|
|
2134
|
-
['filterHueRotate', 'hue-rotate', '0deg'],
|
|
2135
|
-
['filterSepia', 'sepia', '0'],
|
|
2136
|
-
['filterInvert', 'invert', '0'],
|
|
2137
|
-
];
|
|
2138
|
-
// ─── Backdrop-filter ─────────────────────────────────────────────────────────
|
|
2139
|
-
const BACKDROP_FUNS = [
|
|
2140
|
-
['backdropBlur', 'blur', '0px'],
|
|
2141
|
-
['backdropBrightness', 'brightness', '1'],
|
|
2142
|
-
['backdropContrast', 'contrast', '1'],
|
|
2143
|
-
['backdropSaturate', 'saturate', '1'],
|
|
2144
|
-
];
|
|
2145
|
-
// ─── Transform ───────────────────────────────────────────────────────────────
|
|
2146
|
-
const TRANSFORM_FUNS = [
|
|
2147
|
-
['translateX', 'translateX', '0px'],
|
|
2148
|
-
['translateY', 'translateY', '0px'],
|
|
2149
|
-
['scaleX', 'scaleX', '1'],
|
|
2150
|
-
['scaleY', 'scaleY', '1'],
|
|
2151
|
-
['rotate', 'rotate', '0deg'],
|
|
2152
|
-
['skewX', 'skewX', '0deg'],
|
|
2153
|
-
['skewY', 'skewY', '0deg'],
|
|
2154
|
-
];
|
|
2155
|
-
// ─── ::marker pseudo-element ─────────────────────────────────────────────────
|
|
2156
|
-
const MARKER_FUNS = [
|
|
2157
|
-
['markerColor', 'color'],
|
|
2158
|
-
['markerFontSize', 'fontSize'],
|
|
2159
|
-
];
|
|
2160
|
-
// ─── All virtual trait names ──────────────────────────────────────────────────
|
|
2161
|
-
const VIRTUAL_TRAIT_KEYS = new Set([
|
|
2162
|
-
...FILTER_FUNS.map(([t]) => t),
|
|
2163
|
-
...BACKDROP_FUNS.map(([t]) => t),
|
|
2164
|
-
...TRANSFORM_FUNS.map(([t]) => t),
|
|
2165
|
-
'transitionDuration', 'transitionTimingFunction', 'transitionDelay',
|
|
2166
|
-
...MARKER_FUNS.map(([t]) => t),
|
|
2167
|
-
]);
|
|
2168
|
-
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
2169
|
-
function buildFns(funs, src) {
|
|
2170
|
-
return funs
|
|
2171
|
-
.filter(([t, , d]) => src[t] != null && src[t] !== '' && src[t] !== d)
|
|
2172
|
-
.map(([t, fn]) => `${fn}(${src[t]})`)
|
|
2173
|
-
.join(' ');
|
|
2174
|
-
}
|
|
2175
|
-
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
2176
|
-
/**
|
|
2177
|
-
* Strip virtual trait keys from `raw` and emit their composed CSS equivalents.
|
|
2178
|
-
*
|
|
2179
|
-
* When composing a partial state-override map (hover, active…), pass the full
|
|
2180
|
-
* base style map as `base` so non-overridden components (e.g. filterBrightness
|
|
2181
|
-
* when only filterBlur is overridden) are preserved in the composed output.
|
|
2182
|
-
* When operating on a complete style map, omit `base` — it defaults to `raw`.
|
|
2183
|
-
*/
|
|
2184
|
-
function composeVirtualTraits(raw, base = raw) {
|
|
2185
|
-
// Copy all non-virtual properties as-is
|
|
2186
|
-
const result = {};
|
|
2187
|
-
for (const [k, v] of Object.entries(raw)) {
|
|
2188
|
-
if (!VIRTUAL_TRAIT_KEYS.has(k))
|
|
2189
|
-
result[k] = v;
|
|
2190
|
-
}
|
|
2191
|
-
// Merge base values for any virtual key absent from the partial override map
|
|
2192
|
-
const merged = (funs) => {
|
|
2193
|
-
const m = {};
|
|
2194
|
-
for (const [t] of funs)
|
|
2195
|
-
m[t] = raw[t] ?? base[t];
|
|
2196
|
-
return m;
|
|
2197
|
-
};
|
|
2198
|
-
// filter
|
|
2199
|
-
const filterStr = buildFns(FILTER_FUNS, merged(FILTER_FUNS));
|
|
2200
|
-
if (filterStr)
|
|
2201
|
-
result['filter'] = filterStr;
|
|
2202
|
-
// backdrop-filter
|
|
2203
|
-
const backdropStr = buildFns(BACKDROP_FUNS, merged(BACKDROP_FUNS));
|
|
2204
|
-
if (backdropStr)
|
|
2205
|
-
result['backdropFilter'] = backdropStr;
|
|
2206
|
-
// transform
|
|
2207
|
-
const transformStr = buildFns(TRANSFORM_FUNS, merged(TRANSFORM_FUNS));
|
|
2208
|
-
if (transformStr)
|
|
2209
|
-
result['transform'] = transformStr;
|
|
2210
|
-
// transition — only emit when duration is non-zero
|
|
2211
|
-
const dur = raw['transitionDuration'] ?? base['transitionDuration'];
|
|
2212
|
-
const ease = raw['transitionTimingFunction'] ?? base['transitionTimingFunction'];
|
|
2213
|
-
const del = raw['transitionDelay'] ?? base['transitionDelay'];
|
|
2214
|
-
if (dur && ease && dur !== '0ms' && dur !== '0s') {
|
|
2215
|
-
const delPart = del && del !== '0ms' && del !== '0s' ? ` ${del}` : '';
|
|
2216
|
-
result['transition'] = `all ${dur} ${ease}${delPart}`;
|
|
2217
|
-
}
|
|
2218
|
-
// ::marker pseudo-element — collected into __markerStyles so StyleRegistryService
|
|
2219
|
-
// can emit a separate `.iox-node-{id}::marker { … }` rule.
|
|
2220
|
-
const markerStyles = {};
|
|
2221
|
-
for (const [trait, cssProp] of MARKER_FUNS) {
|
|
2222
|
-
const val = raw[trait] ?? base[trait];
|
|
2223
|
-
if (val != null && val !== '')
|
|
2224
|
-
markerStyles[cssProp] = val;
|
|
2225
|
-
}
|
|
2226
|
-
if (Object.keys(markerStyles).length) {
|
|
2227
|
-
result['__markerStyles'] = markerStyles;
|
|
2228
|
-
}
|
|
2229
|
-
return result;
|
|
2230
|
-
}
|
|
2231
|
-
|
|
2232
|
-
/**
|
|
2233
|
-
* Shared data-source resolving pattern — the SINGLE source of truth for how an IoxDataSource
|
|
2234
|
-
* becomes a backend fetch, consumed by BOTH the page builder (iox-cms-client bindings panel) and
|
|
2235
|
-
* the SSR client engine (iox-client-engine DataSourceResolverService). There must be no second
|
|
2236
|
-
* copy of this decision logic; only the transport differs between the two:
|
|
2237
|
-
*
|
|
2238
|
-
* builder → authenticated org-scoped controllers (admin session):
|
|
2239
|
-
* single by id → GET admin.../resources/:type/:id (ContentService.getContentById)
|
|
2240
|
-
* collection → GET admin.../resources/:type (ContentService.getContent)
|
|
2241
|
-
* engine → @Public() controllers (no auth, isPublished filter applied server-side):
|
|
2242
|
-
* single by id → GET api/:type/:id (UserResourcesController)
|
|
2243
|
-
* collection → GET api/:type?filters (UserResourcesController)
|
|
2244
|
-
*
|
|
2245
|
-
* The DECISION (by-id vs collection, which id, which filters, how to pick the single result) is
|
|
2246
|
-
* identical for both — that's what this module owns. Each app maps the returned {@link DataSourcePlan}
|
|
2247
|
-
* onto its own transport. A single item is ALWAYS fetched by its id as a path segment — never a
|
|
2248
|
-
* `?_id=` query — to match the builder's getContentById.
|
|
2249
|
-
*/
|
|
2250
|
-
/**
|
|
2251
|
-
* Match a page route template against an actual path and extract named params.
|
|
2252
|
-
* matchRouteParams('/project/:projectId', 'project/68abc') → { projectId: '68abc' }
|
|
2253
|
-
* Both sides are normalised (leading slash stripped) and split on '/'; values are URL-decoded.
|
|
2254
|
-
*/
|
|
2255
|
-
function matchRouteParams(template, actual) {
|
|
2256
|
-
const t = (template ?? '').replace(/^\/+/, '').split('/');
|
|
2257
|
-
const a = (actual ?? '').replace(/^\/+/, '').split('/');
|
|
2258
|
-
const params = {};
|
|
2259
|
-
for (let i = 0; i < t.length; i++) {
|
|
2260
|
-
const seg = t[i];
|
|
2261
|
-
if (seg && seg.startsWith(':'))
|
|
2262
|
-
params[seg.slice(1)] = decodeURIComponent(a[i] ?? '');
|
|
2263
|
-
}
|
|
2264
|
-
return params;
|
|
2265
|
-
}
|
|
2266
|
-
/**
|
|
2267
|
-
* Build the query filter for a data-source request from its filterByRouteParam / filterByQueryParams
|
|
2268
|
-
* config. `field` is filtered by the route-param value (falling back to `defaultId`); query filters
|
|
2269
|
-
* map each `field` to a query-param value (falling back to `defaultValue`), and a filter with NO
|
|
2270
|
-
* `queryParam` is a constant on `defaultValue`. Empty values are omitted so the source isn't
|
|
2271
|
-
* accidentally filtered to nothing.
|
|
2272
|
-
*/
|
|
2273
|
-
function buildDataSourceFilter(req, routeParams = {}, queryParams = {}) {
|
|
2274
|
-
const params = {};
|
|
2275
|
-
const byRoute = req?.filterByRouteParam;
|
|
2276
|
-
if (byRoute?.field && byRoute?.routeParam) {
|
|
2277
|
-
const value = routeParams[byRoute.routeParam] ?? byRoute.defaultId;
|
|
2278
|
-
if (value !== undefined && value !== null && value !== '')
|
|
2279
|
-
params[byRoute.field] = value;
|
|
2280
|
-
}
|
|
2281
|
-
for (const q of req?.filterByQueryParams ?? []) {
|
|
2282
|
-
if (!q?.field)
|
|
2283
|
-
continue;
|
|
2284
|
-
// No queryParam → a CONSTANT filter: defaultValue is the value, in the builder preview and
|
|
2285
|
-
// on the live site alike. With a queryParam the URL wins and defaultValue is the fallback.
|
|
2286
|
-
const value = q.queryParam ? (queryParams[q.queryParam] ?? q.defaultValue) : q.defaultValue;
|
|
2287
|
-
if (value !== undefined && value !== null && value !== '')
|
|
2288
|
-
params[q.field] = value;
|
|
2289
|
-
}
|
|
2290
|
-
return params;
|
|
2291
|
-
}
|
|
2292
|
-
/**
|
|
2293
|
-
* Mirrors the builder's getContentById decision. When a SINGLE source is filtered by the item id
|
|
2294
|
-
* (`_id`) via a route param, the item is fetched by PATH (`.../:type/:id`), not a `?_id=` query.
|
|
2295
|
-
* Returns that id (route-param value, else `defaultId`), or `null` when the source isn't a
|
|
2296
|
-
* single-by-id fetch — in which case the caller uses the collection endpoint + filters. The
|
|
2297
|
-
* path-based form only applies to the id field; a single source filtered by any other field still
|
|
2298
|
-
* goes through the collection endpoint.
|
|
2299
|
-
*/
|
|
2300
|
-
function resolveSingleItemId(req, routeParams = {}) {
|
|
2301
|
-
const byRoute = req?.filterByRouteParam;
|
|
2302
|
-
if (!byRoute?.routeParam)
|
|
2303
|
-
return null;
|
|
2304
|
-
if (byRoute.field && byRoute.field !== '_id')
|
|
2305
|
-
return null;
|
|
2306
|
-
const value = routeParams[byRoute.routeParam] ?? byRoute.defaultId;
|
|
2307
|
-
return value !== undefined && value !== null && value !== '' ? String(value) : null;
|
|
2308
|
-
}
|
|
2309
|
-
/**
|
|
2310
|
-
* Resolve an IoxDataSource into a transport-agnostic {@link DataSourcePlan}. This is the shared
|
|
2311
|
-
* decision both the builder and the SSR engine run; each then executes the plan with its own
|
|
2312
|
-
* HTTP layer. Single sources with a route-param id resolve to a `by-id` (path) fetch — exactly like
|
|
2313
|
-
* the builder's getContentById.
|
|
2314
|
-
*/
|
|
2315
|
-
function planDataSource(ds, routeParams = {}, queryParams = {}) {
|
|
2316
|
-
const mode = ds?.mode === 'single' ? 'single' : 'list';
|
|
2317
|
-
switch (ds?.type) {
|
|
2318
|
-
case 'cms':
|
|
2319
|
-
case 'shop': {
|
|
2320
|
-
const domain = ds.type;
|
|
2321
|
-
const req = domain === 'cms' ? ds.request?.cms : ds.request?.shop;
|
|
2322
|
-
const resource = req?.contentType;
|
|
2323
|
-
if (!resource)
|
|
2324
|
-
return { kind: 'none' };
|
|
2325
|
-
const id = mode === 'single' ? resolveSingleItemId(req, routeParams) : null;
|
|
2326
|
-
if (id)
|
|
2327
|
-
return { kind: 'by-id', domain, resource, id, mode };
|
|
2328
|
-
const filter = buildDataSourceFilter(req, routeParams, queryParams);
|
|
2329
|
-
const plan = { kind: 'collection', domain, resource, filter, mode };
|
|
2330
|
-
if (req?.limit)
|
|
2331
|
-
plan.limit = req.limit;
|
|
2332
|
-
return plan;
|
|
2333
|
-
}
|
|
2334
|
-
case 'external-api': {
|
|
2335
|
-
const endpoint = ds.request?.endpoint;
|
|
2336
|
-
return endpoint ? { kind: 'external', endpoint, mode } : { kind: 'none' };
|
|
2337
|
-
}
|
|
2338
|
-
case 'static':
|
|
2339
|
-
return { kind: 'static', data: ds.request?.data ?? null, mode };
|
|
2340
|
-
case 'derived': {
|
|
2341
|
-
const d = ds.request?.derived;
|
|
2342
|
-
return d?.from ? { kind: 'derived', from: d.from, path: d.path ?? '', mode } : { kind: 'none' };
|
|
2343
|
-
}
|
|
2344
|
-
default:
|
|
2345
|
-
return { kind: 'none' };
|
|
2714
|
+
// builder Icon component's `:host{display:inline-flex}`.
|
|
2715
|
+
if (type === 'Icon' && sp['display'] === undefined) {
|
|
2716
|
+
out.push('display: inline-flex', 'align-items: center', 'justify-content: center');
|
|
2346
2717
|
}
|
|
2718
|
+
return out;
|
|
2347
2719
|
}
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2720
|
+
|
|
2721
|
+
// Virtual trait → composed CSS property helpers.
|
|
2722
|
+
//
|
|
2723
|
+
// Several style traits are "virtual" — they don't map 1:1 to a CSS property but
|
|
2724
|
+
// are components of a multi-function CSS value. composeVirtualTraits() strips
|
|
2725
|
+
// these virtual keys from the style map and replaces them with the correctly
|
|
2726
|
+
// composed CSS properties (filter, backdrop-filter, transform, transition).
|
|
2727
|
+
//
|
|
2728
|
+
// SHARED (iox-ui): consumed by BOTH the builder (render.directive + style panel)
|
|
2729
|
+
// and the SSR engine (LayoutRendererService). It MUST live here, not in iox-builder,
|
|
2730
|
+
// because the engine does not depend on iox-builder — without it the engine emitted
|
|
2731
|
+
// the raw virtual keys (`translate-x`, `filter-blur`, `scale-x`…) as invalid CSS.
|
|
2732
|
+
// Run this BEFORE compileDeclarations so the CSS output is identical on both sides.
|
|
2733
|
+
// ─── Filter ──────────────────────────────────────────────────────────────────
|
|
2734
|
+
const FILTER_FUNS = [
|
|
2735
|
+
['filterBlur', 'blur', '0px'],
|
|
2736
|
+
['filterBrightness', 'brightness', '1'],
|
|
2737
|
+
['filterContrast', 'contrast', '1'],
|
|
2738
|
+
['filterGrayscale', 'grayscale', '0'],
|
|
2739
|
+
['filterSaturate', 'saturate', '1'],
|
|
2740
|
+
['filterHueRotate', 'hue-rotate', '0deg'],
|
|
2741
|
+
['filterSepia', 'sepia', '0'],
|
|
2742
|
+
['filterInvert', 'invert', '0'],
|
|
2743
|
+
];
|
|
2744
|
+
// ─── Backdrop-filter ─────────────────────────────────────────────────────────
|
|
2745
|
+
const BACKDROP_FUNS = [
|
|
2746
|
+
['backdropBlur', 'blur', '0px'],
|
|
2747
|
+
['backdropBrightness', 'brightness', '1'],
|
|
2748
|
+
['backdropContrast', 'contrast', '1'],
|
|
2749
|
+
['backdropSaturate', 'saturate', '1'],
|
|
2750
|
+
];
|
|
2751
|
+
// ─── Transform ───────────────────────────────────────────────────────────────
|
|
2752
|
+
const TRANSFORM_FUNS = [
|
|
2753
|
+
['translateX', 'translateX', '0px'],
|
|
2754
|
+
['translateY', 'translateY', '0px'],
|
|
2755
|
+
['scaleX', 'scaleX', '1'],
|
|
2756
|
+
['scaleY', 'scaleY', '1'],
|
|
2757
|
+
['rotate', 'rotate', '0deg'],
|
|
2758
|
+
['skewX', 'skewX', '0deg'],
|
|
2759
|
+
['skewY', 'skewY', '0deg'],
|
|
2760
|
+
];
|
|
2761
|
+
// ─── ::marker pseudo-element ─────────────────────────────────────────────────
|
|
2762
|
+
const MARKER_FUNS = [
|
|
2763
|
+
['markerColor', 'color'],
|
|
2764
|
+
['markerFontSize', 'fontSize'],
|
|
2765
|
+
];
|
|
2766
|
+
// ─── All virtual trait names ──────────────────────────────────────────────────
|
|
2767
|
+
const VIRTUAL_TRAIT_KEYS = new Set([
|
|
2768
|
+
...FILTER_FUNS.map(([t]) => t),
|
|
2769
|
+
...BACKDROP_FUNS.map(([t]) => t),
|
|
2770
|
+
...TRANSFORM_FUNS.map(([t]) => t),
|
|
2771
|
+
'transitionDuration', 'transitionTimingFunction', 'transitionDelay',
|
|
2772
|
+
...MARKER_FUNS.map(([t]) => t),
|
|
2773
|
+
]);
|
|
2774
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
2775
|
+
function buildFns(funs, src) {
|
|
2776
|
+
return funs
|
|
2777
|
+
.filter(([t, , d]) => src[t] != null && src[t] !== '' && src[t] !== d)
|
|
2778
|
+
.map(([t, fn]) => `${fn}(${src[t]})`)
|
|
2779
|
+
.join(' ');
|
|
2372
2780
|
}
|
|
2781
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
2373
2782
|
/**
|
|
2374
|
-
*
|
|
2375
|
-
*
|
|
2376
|
-
*
|
|
2377
|
-
*
|
|
2378
|
-
*
|
|
2379
|
-
*
|
|
2380
|
-
*/
|
|
2381
|
-
/**
|
|
2382
|
-
* Fill in `derived` aliases in an already-resolved map. Given the data sources and a map of resolved
|
|
2383
|
-
* NON-derived values (by alias), compute each `type:'derived'` alias as `getByPath(parent, path)` in
|
|
2384
|
-
* dependency order (chains resolved by iterating until stable). Mutates + returns the map. Used by the
|
|
2385
|
-
* SSR engine after it resolves the root sources; the builder resolves derived lazily per alias.
|
|
2783
|
+
* Strip virtual trait keys from `raw` and emit their composed CSS equivalents.
|
|
2784
|
+
*
|
|
2785
|
+
* When composing a partial state-override map (hover, active…), pass the full
|
|
2786
|
+
* base style map as `base` so non-overridden components (e.g. filterBrightness
|
|
2787
|
+
* when only filterBlur is overridden) are preserved in the composed output.
|
|
2788
|
+
* When operating on a complete style map, omit `base` — it defaults to `raw`.
|
|
2386
2789
|
*/
|
|
2387
|
-
function
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
if (d.alias in resolved)
|
|
2394
|
-
continue;
|
|
2395
|
-
const from = d.request?.derived?.from;
|
|
2396
|
-
if (from && from in resolved) {
|
|
2397
|
-
resolved[d.alias] = getByPath(resolved[from], d.request.derived.path ?? '');
|
|
2398
|
-
changed = true;
|
|
2399
|
-
}
|
|
2400
|
-
}
|
|
2401
|
-
}
|
|
2402
|
-
return resolved;
|
|
2403
|
-
}
|
|
2404
|
-
function resolveRepeaterItems(resolved, sourcePath) {
|
|
2405
|
-
if (sourcePath) {
|
|
2406
|
-
const nested = getByPath(resolved, sourcePath);
|
|
2407
|
-
return Array.isArray(nested) ? nested : [];
|
|
2408
|
-
}
|
|
2409
|
-
if (Array.isArray(resolved))
|
|
2410
|
-
return resolved;
|
|
2411
|
-
return resolved == null ? [] : [resolved];
|
|
2412
|
-
}
|
|
2413
|
-
// ── Referenced data sources (shared: builder-save + SSR resolve) ─────────────────────────────────
|
|
2414
|
-
// Which data-source aliases a node tree uses, so a consumer resolves ONLY what a page/global needs
|
|
2415
|
-
// (not the whole org library). Used by cms-client (persist globals' sources) and the SSR engine
|
|
2416
|
-
// (resolve the referenced subset per page). See org-data-sources-plan.md.
|
|
2417
|
-
/** Aliases referenced anywhere in a node tree: a repeater/slider `source` (a `bindings` entry with
|
|
2418
|
-
* `prop === 'source'`, or `props.source` for the slider) plus every binding's `source`. */
|
|
2419
|
-
function collectReferencedAliases(nodes, acc = new Set()) {
|
|
2420
|
-
for (const n of nodes ?? []) {
|
|
2421
|
-
for (const b of n?.bindings ?? []) {
|
|
2422
|
-
if (b?.source)
|
|
2423
|
-
acc.add(b.source);
|
|
2424
|
-
}
|
|
2425
|
-
if (n?.props?.source)
|
|
2426
|
-
acc.add(n.props.source);
|
|
2427
|
-
if (n?.children?.length)
|
|
2428
|
-
collectReferencedAliases(n.children, acc);
|
|
2790
|
+
function composeVirtualTraits(raw, base = raw) {
|
|
2791
|
+
// Copy all non-virtual properties as-is
|
|
2792
|
+
const result = {};
|
|
2793
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
2794
|
+
if (!VIRTUAL_TRAIT_KEYS.has(k))
|
|
2795
|
+
result[k] = v;
|
|
2429
2796
|
}
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
}
|
|
2437
|
-
/** Like `referencedDataSources`, but also pulls in a referenced source's DEPENDENCIES (transitively),
|
|
2438
|
-
* none of which the layout names directly:
|
|
2439
|
-
* - a `derived` alias needs its `request.derived.from` parent;
|
|
2440
|
-
* - a `relative` (route-context) alias needs its `request.relative.subjectFrom` (the current item)
|
|
2441
|
-
* and `request.relative.from` (the collection it navigates).
|
|
2442
|
-
* Use this when RESOLVING (the engine) so derived views and siblings/related don't resolve to nothing. */
|
|
2443
|
-
function referencedDataSourcesDeep(nodes, all) {
|
|
2444
|
-
const byAlias = new Map((all ?? []).map(d => [d?.alias, d]));
|
|
2445
|
-
const wanted = collectReferencedAliases(nodes);
|
|
2446
|
-
const queue = [...wanted];
|
|
2447
|
-
const enqueue = (alias) => {
|
|
2448
|
-
if (alias && !wanted.has(alias)) {
|
|
2449
|
-
wanted.add(alias);
|
|
2450
|
-
queue.push(alias);
|
|
2451
|
-
}
|
|
2797
|
+
// Merge base values for any virtual key absent from the partial override map
|
|
2798
|
+
const merged = (funs) => {
|
|
2799
|
+
const m = {};
|
|
2800
|
+
for (const [t] of funs)
|
|
2801
|
+
m[t] = raw[t] ?? base[t];
|
|
2802
|
+
return m;
|
|
2452
2803
|
};
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2804
|
+
// filter
|
|
2805
|
+
const filterStr = buildFns(FILTER_FUNS, merged(FILTER_FUNS));
|
|
2806
|
+
if (filterStr)
|
|
2807
|
+
result['filter'] = filterStr;
|
|
2808
|
+
// backdrop-filter
|
|
2809
|
+
const backdropStr = buildFns(BACKDROP_FUNS, merged(BACKDROP_FUNS));
|
|
2810
|
+
if (backdropStr)
|
|
2811
|
+
result['backdropFilter'] = backdropStr;
|
|
2812
|
+
// transform
|
|
2813
|
+
const transformStr = buildFns(TRANSFORM_FUNS, merged(TRANSFORM_FUNS));
|
|
2814
|
+
if (transformStr)
|
|
2815
|
+
result['transform'] = transformStr;
|
|
2816
|
+
// transition — only emit when duration is non-zero
|
|
2817
|
+
const dur = raw['transitionDuration'] ?? base['transitionDuration'];
|
|
2818
|
+
const ease = raw['transitionTimingFunction'] ?? base['transitionTimingFunction'];
|
|
2819
|
+
const del = raw['transitionDelay'] ?? base['transitionDelay'];
|
|
2820
|
+
if (dur && ease && dur !== '0ms' && dur !== '0s') {
|
|
2821
|
+
const delPart = del && del !== '0ms' && del !== '0s' ? ` ${del}` : '';
|
|
2822
|
+
result['transition'] = `all ${dur} ${ease}${delPart}`;
|
|
2458
2823
|
}
|
|
2459
|
-
|
|
2460
|
-
}
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
switch (plan.kind) {
|
|
2470
|
-
case 'by-id':
|
|
2471
|
-
return { url: `${baseFor(plan.domain)}${plan.resource}/${encodeURIComponent(plan.id)}`, params: {} };
|
|
2472
|
-
case 'collection': {
|
|
2473
|
-
const params = { ...plan.filter };
|
|
2474
|
-
if (plan.limit)
|
|
2475
|
-
params['limit'] = plan.limit;
|
|
2476
|
-
return { url: `${baseFor(plan.domain)}${plan.resource}`, params };
|
|
2477
|
-
}
|
|
2478
|
-
case 'external':
|
|
2479
|
-
return { url: plan.endpoint, params: {} };
|
|
2480
|
-
default:
|
|
2481
|
-
return null;
|
|
2824
|
+
// ::marker pseudo-element — collected into __markerStyles so StyleRegistryService
|
|
2825
|
+
// can emit a separate `.iox-node-{id}::marker { … }` rule.
|
|
2826
|
+
const markerStyles = {};
|
|
2827
|
+
for (const [trait, cssProp] of MARKER_FUNS) {
|
|
2828
|
+
const val = raw[trait] ?? base[trait];
|
|
2829
|
+
if (val != null && val !== '')
|
|
2830
|
+
markerStyles[cssProp] = val;
|
|
2831
|
+
}
|
|
2832
|
+
if (Object.keys(markerStyles).length) {
|
|
2833
|
+
result['__markerStyles'] = markerStyles;
|
|
2482
2834
|
}
|
|
2835
|
+
return result;
|
|
2483
2836
|
}
|
|
2484
2837
|
|
|
2485
2838
|
/**
|
|
@@ -2653,6 +3006,106 @@ function resolveRelativeInto(dataSources, resolved, routeParams = {}) {
|
|
|
2653
3006
|
return resolved;
|
|
2654
3007
|
}
|
|
2655
3008
|
|
|
3009
|
+
/**
|
|
3010
|
+
* Binding pipes — render-time value transforms (UI label: "Filters").
|
|
3011
|
+
*
|
|
3012
|
+
* A binding resolves a raw value (`resolvePath(sourceData, path)`); a pipe chain then reshapes it for
|
|
3013
|
+
* display (format a timestamp, upper-case, truncate, …). This is the SINGLE shared implementation used
|
|
3014
|
+
* by BOTH the builder canvas and the SSR engine — the engine renders layout JSON to a STRING and can't
|
|
3015
|
+
* use Angular pipes, and `resolvePath` is already duplicated across the apply points, so the transform
|
|
3016
|
+
* MUST be one pure, transport-agnostic function applied identically everywhere. Never fork it.
|
|
3017
|
+
*
|
|
3018
|
+
* See iox-ai-guidance/architecture/builder/binding-filters-plan.md.
|
|
3019
|
+
*/
|
|
3020
|
+
// ── Date / time (Intl locale presets — locale-aware, zero-dep, browser + Node identical) ──────────
|
|
3021
|
+
const DATE_PRESETS = {
|
|
3022
|
+
short: { dateStyle: 'short' },
|
|
3023
|
+
medium: { dateStyle: 'medium' },
|
|
3024
|
+
long: { dateStyle: 'long' },
|
|
3025
|
+
full: { dateStyle: 'full' },
|
|
3026
|
+
};
|
|
3027
|
+
const DATETIME_PRESETS = {
|
|
3028
|
+
short: { dateStyle: 'short', timeStyle: 'short' },
|
|
3029
|
+
medium: { dateStyle: 'medium', timeStyle: 'short' },
|
|
3030
|
+
long: { dateStyle: 'long', timeStyle: 'medium' },
|
|
3031
|
+
full: { dateStyle: 'full', timeStyle: 'long' },
|
|
3032
|
+
};
|
|
3033
|
+
/** Coerce an ISO string / epoch number / Date to a valid Date, or null (→ pipes pass the value through). */
|
|
3034
|
+
function toDate(value) {
|
|
3035
|
+
if (value instanceof Date)
|
|
3036
|
+
return isNaN(value.getTime()) ? null : value;
|
|
3037
|
+
if (typeof value === 'number') {
|
|
3038
|
+
const d = new Date(value);
|
|
3039
|
+
return isNaN(d.getTime()) ? null : d;
|
|
3040
|
+
}
|
|
3041
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
3042
|
+
const d = new Date(value);
|
|
3043
|
+
return isNaN(d.getTime()) ? null : d;
|
|
3044
|
+
}
|
|
3045
|
+
return null;
|
|
3046
|
+
}
|
|
3047
|
+
function formatDate(value, args, ctx, presets) {
|
|
3048
|
+
const d = toDate(value);
|
|
3049
|
+
if (!d)
|
|
3050
|
+
return value; // never throw — unparseable input passes through unchanged
|
|
3051
|
+
const preset = args[0] || 'medium';
|
|
3052
|
+
return new Intl.DateTimeFormat(ctx.locale, presets[preset] ?? presets['medium']).format(d);
|
|
3053
|
+
}
|
|
3054
|
+
/** The registry — adding a pipe here (+ a descriptor below) makes it work on both sides and appear in
|
|
3055
|
+
* the authoring UI. Keyed by name; unknown names are passed through by `applyBindingPipes`. */
|
|
3056
|
+
const PIPE_REGISTRY = {
|
|
3057
|
+
date: (v, args, ctx) => formatDate(v, args, ctx, DATE_PRESETS),
|
|
3058
|
+
datetime: (v, args, ctx) => formatDate(v, args, ctx, DATETIME_PRESETS),
|
|
3059
|
+
uppercase: (v) => (v == null ? v : String(v).toUpperCase()),
|
|
3060
|
+
lowercase: (v) => (v == null ? v : String(v).toLowerCase()),
|
|
3061
|
+
truncate: (v, args) => {
|
|
3062
|
+
if (v == null)
|
|
3063
|
+
return v;
|
|
3064
|
+
const s = String(v);
|
|
3065
|
+
const len = Number(args[0]) || 100;
|
|
3066
|
+
const suffix = args[1] != null ? String(args[1]) : '…';
|
|
3067
|
+
return s.length > len ? s.slice(0, len) + suffix : s;
|
|
3068
|
+
},
|
|
3069
|
+
number: (v, args, ctx) => {
|
|
3070
|
+
const n = typeof v === 'number' ? v : parseFloat(v);
|
|
3071
|
+
if (!Number.isFinite(n))
|
|
3072
|
+
return v;
|
|
3073
|
+
const opts = {};
|
|
3074
|
+
if (args[0] != null && args[0] !== '') {
|
|
3075
|
+
opts.minimumFractionDigits = Number(args[0]);
|
|
3076
|
+
opts.maximumFractionDigits = Number(args[0]);
|
|
3077
|
+
}
|
|
3078
|
+
return new Intl.NumberFormat(ctx.locale, opts).format(n);
|
|
3079
|
+
},
|
|
3080
|
+
};
|
|
3081
|
+
/**
|
|
3082
|
+
* Apply a binding's pipe chain to a resolved value, LEFT→RIGHT. Pure. An empty/undefined chain returns
|
|
3083
|
+
* the value untouched; an unknown pipe name is skipped (forward-compatible with newer authored pipes).
|
|
3084
|
+
* Call this at every binding-resolution point (builder canvas + engine SSR + engine hydration).
|
|
3085
|
+
*/
|
|
3086
|
+
function applyBindingPipes(value, pipes, ctx = {}) {
|
|
3087
|
+
if (!pipes?.length)
|
|
3088
|
+
return value;
|
|
3089
|
+
return pipes.reduce((v, p) => {
|
|
3090
|
+
const fn = p && PIPE_REGISTRY[p.name];
|
|
3091
|
+
return fn ? fn(v, p.args ?? [], ctx) : v;
|
|
3092
|
+
}, value);
|
|
3093
|
+
}
|
|
3094
|
+
const PRESET_OPTIONS = [
|
|
3095
|
+
{ label: 'Short', value: 'short' },
|
|
3096
|
+
{ label: 'Medium', value: 'medium' },
|
|
3097
|
+
{ label: 'Long', value: 'long' },
|
|
3098
|
+
{ label: 'Full', value: 'full' },
|
|
3099
|
+
];
|
|
3100
|
+
const PIPE_DESCRIPTORS = [
|
|
3101
|
+
{ name: 'date', label: 'Date', args: [{ key: 'preset', label: 'Format', control: 'select', options: PRESET_OPTIONS, default: 'medium' }] },
|
|
3102
|
+
{ name: 'datetime', label: 'Date & time', args: [{ key: 'preset', label: 'Format', control: 'select', options: PRESET_OPTIONS, default: 'medium' }] },
|
|
3103
|
+
{ name: 'uppercase', label: 'UPPERCASE', args: [] },
|
|
3104
|
+
{ name: 'lowercase', label: 'lowercase', args: [] },
|
|
3105
|
+
{ name: 'truncate', label: 'Truncate', args: [{ key: 'length', label: 'Max length', control: 'number', default: 100 }] },
|
|
3106
|
+
{ name: 'number', label: 'Number', args: [{ key: 'decimals', label: 'Decimals', control: 'number', default: null }] },
|
|
3107
|
+
];
|
|
3108
|
+
|
|
2656
3109
|
/**
|
|
2657
3110
|
* Cinematic page-transition presets — the SINGLE SOURCE OF TRUTH shared by the page builder
|
|
2658
3111
|
* (preview) and the SSR client engine (production). Pure, transport-agnostic: this module only
|
|
@@ -3087,5 +3540,5 @@ function effectiveTransitionId(anim) {
|
|
|
3087
3540
|
* Generated bundle index. Do not edit.
|
|
3088
3541
|
*/
|
|
3089
3542
|
|
|
3090
|
-
export { AOSService, AnalyticsService, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, CMSClientInfraModule, CardComponent, ClientListComponent, ClientListItemComponent, ClientRepeaterComponent, ClientSliderContainerComponent, ClientSliderSlideComponent, ComponentInstanceRegistryService, ConsentService, ContainerComponent, ContentService, DEFAULT_PAGE_TRANSITION, DEFAULT_PAGE_TRANSITION_DURATION, DEFAULT_PAGE_TRANSITION_EASING, DEFAULT_PAGE_TRANSITION_PERSPECTIVE, DEFAULT_WIDTH_BY_TYPE, ENVIRONMENT, IOX_BUILDER_EVENTS, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxPageComponent, IoxPageModule, IoxUiModule, LinkedContainerComponent, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PageScrollProvider, PrivacyModalComponent, PrivacyModalService, PrivacyModelEvent, RESERVED_ALIAS_PREFIX, SUPPRESSED_STYLE_PROPS_BY_TYPE, SectionComponent, TRANSITION_FEELS, TextBlockComponent, VIRTUAL_TRAIT_KEYS, ViewPositionDirective, ViewPositionModule, WebSettingsService, applyFeel, buildDataSourceFilter, buildRelatedFilter, buildTransitionTimeline, collectReferencedAliases, compileDeclarations, composeVirtualTraits, computeRelative, dataSourcePlanToRequest, effectiveTransitionId, excludeSelf, feelFor, getByPath, isReservedAlias, legacyEnterToTransition, matchRouteParams, normalizeCollectionResult, planDataSource, referencedDataSources, referencedDataSourcesDeep, renderDefaultDeclarations, resolveDerivedInto, resolvePageTransition, resolveRelativeInto, resolveRepeaterItems, resolveSiblings, resolveSingleItemId, rewriteViewportUnits, stripSuppressedProps, styleKeyToKebab };
|
|
3543
|
+
export { AOSService, AnalyticsService, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, CMSClientInfraModule, CardComponent, ClientGalleryComponent, ClientListComponent, ClientListItemComponent, ClientRepeaterComponent, ClientSliderContainerComponent, ClientSliderSlideComponent, ComponentInstanceRegistryService, ConsentService, ContainerComponent, ContentService, DEFAULT_GALLERY_SCHEDULER, DEFAULT_PAGE_TRANSITION, DEFAULT_PAGE_TRANSITION_DURATION, DEFAULT_PAGE_TRANSITION_EASING, DEFAULT_PAGE_TRANSITION_PERSPECTIVE, DEFAULT_WIDTH_BY_TYPE, ENVIRONMENT, GALLERY_EFFECTS, GALLERY_EFFECT_DESCRIPTORS, GalleryLoop, IOX_BUILDER_EVENTS, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxPageComponent, IoxPageModule, IoxUiModule, LinkedContainerComponent, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PIPE_DESCRIPTORS, PIPE_REGISTRY, PageScrollProvider, PrivacyModalComponent, PrivacyModalService, PrivacyModelEvent, RESERVED_ALIAS_PREFIX, SUPPRESSED_STYLE_PROPS_BY_TYPE, SectionComponent, TRANSITION_FEELS, TextBlockComponent, VIRTUAL_TRAIT_KEYS, ViewPositionDirective, ViewPositionModule, WebSettingsService, applyBindingPipes, applyFeel, buildDataSourceFilter, buildRelatedFilter, buildTransitionTimeline, collectReferencedAliases, compileDeclarations, composeVirtualTraits, computeRelative, dataSourcePlanToRequest, effectiveTransitionId, excludeSelf, feelFor, getByPath, isReservedAlias, legacyEnterToTransition, matchRouteParams, normalizeCollectionResult, parseUrlList, planDataSource, referencedDataSources, referencedDataSourcesDeep, renderDefaultDeclarations, resolveDerivedInto, resolveGalleryEffect, resolveGalleryUrls, resolvePageTransition, resolveRelativeInto, resolveRepeaterItems, resolveSiblings, resolveSingleItemId, rewriteViewportUnits, stripSuppressedProps, styleKeyToKebab };
|
|
3091
3544
|
//# sourceMappingURL=vectoriox-iox-ui.mjs.map
|