mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-14 18:01:20 +08:00
51a88b9566
The install command is the one line a reader comes to a catalog page to copy, and it sat below the whole variables explorer, under its Customize panel, a screen or more down on long items. Move the Install section above the preview and regenerate all 399 pages.
1938 lines
75 KiB
React
1938 lines
75 KiB
React
/**
|
|
* The interactive variables explorer on a catalog page.
|
|
*
|
|
* Replaces the static Variables table and the paste-ready snippet that used to
|
|
* sit under it. A table tells a reader that `glow` accepts `none | standard |
|
|
* strong`; it cannot tell them what `strong` looks like. This can, so the two
|
|
* halves are one control: change it here, watch the preview above change.
|
|
*
|
|
* How a value reaches the running composition
|
|
* -------------------------------------------
|
|
* A composition reads its variables once, at init, before any of this exists.
|
|
* So there is no "apply" path into a live one — the value has to be present
|
|
* before the composition boots. The preview wrapper (generated by
|
|
* scripts/generate-catalog-pages.ts alongside this) listens for the message
|
|
* posted below and reloads only the inner composition frame with the values in
|
|
* its query string, carrying the playhead across. The page, the wrapper and
|
|
* this panel never blink; the reload is one nested iframe deep.
|
|
*
|
|
* Which is why the post is debounced. A dragged range input fires an event per
|
|
* pixel, and an unthrottled reload per pixel is a reload storm that never
|
|
* settles. The control itself is never debounced — it tracks the pointer
|
|
* exactly, because a control that lags its own input feels broken.
|
|
*
|
|
* Why the colours are not utility classes
|
|
* ---------------------------------------
|
|
* Mintlify serves a prebuilt Tailwind, so a `dark:` utility only resolves if
|
|
* some other page already used it. `dark:bg-zinc-700` and `dark:text-zinc-100`
|
|
* both silently no-op here, which renders white text on a white pill. Layout
|
|
* utilities are safe and used throughout; every colour comes from the two token
|
|
* blocks below, where this file owns both the values and the cascade order.
|
|
*
|
|
* The three tabs, and why two of them are highlighted differently
|
|
* ----------------------------------------------------------------
|
|
* Preview is the running composition. Code is its source. Snippet is the mount
|
|
* element carrying whatever the reader has just dialled in — the one thing on
|
|
* the page they are meant to copy, and the reason the panel below exists.
|
|
*
|
|
* Mintlify highlights a fenced block at build time with shiki. The source is
|
|
* static, so it goes through a real fence: the generator writes it as ```html
|
|
* children of this component and it arrives already coloured, indistinguishable
|
|
* from every other code block on the site, with no colour table here involved.
|
|
*
|
|
* The snippet cannot. It changes on every keystroke and every drag, and there
|
|
* is no build step at that point. So it is assembled token by token into the
|
|
* markup a fence would have produced and handed to <CodeBlock>, which supplies
|
|
* the same container, filename header and copy button. SHIKI below is that
|
|
* theme's palette, copied verbatim from a rendered fence on this same site.
|
|
*
|
|
* The one control that is not just a control
|
|
* ------------------------------------------
|
|
* A variable holding raw SVG path data cannot be authored in a text field, so
|
|
* that field also takes a file. The exports at the foot of this file are what
|
|
* turn a dropped or picked SVG into path data in the composition's own
|
|
* coordinate space; the panel writes the result through `onChange` like any
|
|
* other edit, so it reaches the preview by the same debounced post as the rest.
|
|
*/
|
|
|
|
export const VariablesExplorer = ({
|
|
previewSrc,
|
|
compositionId,
|
|
compositionSrc,
|
|
variables,
|
|
children,
|
|
}) => {
|
|
/**
|
|
* github-light-default / dark-plus, the pair Mintlify renders fences with.
|
|
* The light value is the `color`, the dark one the `--shiki-dark` custom
|
|
* property the site's own stylesheet swaps in — shiki's own two-value scheme.
|
|
*
|
|
* Inside the component, not beside it: MDX only carries a snippet's exported
|
|
* bindings into the page, so a bare module-level `const` is undefined by the
|
|
* time this renders.
|
|
*/
|
|
const SHIKI = {
|
|
punct: { color: "rgb(31, 35, 40)", "--shiki-dark": "#808080" },
|
|
tag: { color: "rgb(17, 99, 41)", "--shiki-dark": "#569CD6" },
|
|
attr: { color: "rgb(5, 80, 174)", "--shiki-dark": "#9CDCFE" },
|
|
equals: { color: "rgb(31, 35, 40)", "--shiki-dark": "#D4D4D4" },
|
|
value: { color: "rgb(10, 48, 105)", "--shiki-dark": "#CE9178" },
|
|
};
|
|
|
|
const CSS = `
|
|
.hf-ve {
|
|
--ve-fg: #18181b;
|
|
--ve-muted: #71717a;
|
|
--ve-line: #e4e4e7;
|
|
--ve-surface: #ffffff;
|
|
--ve-sunken: #fafafa;
|
|
--ve-hover: #f4f4f5;
|
|
--ve-on-bg: #18181b;
|
|
--ve-on-fg: #ffffff;
|
|
--ve-ring: rgba(24, 24, 27, 0.14);
|
|
--ve-danger: #b42318;
|
|
}
|
|
:where(html.dark) .hf-ve {
|
|
--ve-fg: #f4f4f5;
|
|
--ve-muted: #a1a1aa;
|
|
--ve-line: #27272a;
|
|
--ve-surface: #18181b;
|
|
--ve-sunken: #131316;
|
|
--ve-hover: #27272a;
|
|
--ve-on-bg: #f4f4f5;
|
|
--ve-on-fg: #18181b;
|
|
--ve-ring: rgba(244, 244, 245, 0.2);
|
|
--ve-danger: #ff9d95;
|
|
}
|
|
|
|
.hf-ve-tabs {
|
|
display: inline-flex;
|
|
gap: 2px;
|
|
padding: 3px;
|
|
border: 1px solid var(--ve-line);
|
|
border-radius: 9999px;
|
|
background: var(--ve-sunken);
|
|
}
|
|
.hf-ve-tab {
|
|
padding: 4px 12px;
|
|
border-radius: 9999px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
color: var(--ve-muted);
|
|
background: transparent;
|
|
}
|
|
.hf-ve-tab[data-on="true"] {
|
|
color: var(--ve-fg);
|
|
background: var(--ve-surface);
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
|
}
|
|
.hf-ve-tab:hover:not([data-on="true"]) { color: var(--ve-fg); }
|
|
|
|
/* Not a grid. A grid row is as tall as its tallest cell, so a five-line snippet
|
|
sat in the preview's 16:9 box with 300px of dead area under it. The inactive
|
|
pane is taken out of flow instead — and stretches left/right rather than to
|
|
inset 0, so the iframe keeps its own height. An iframe resized on every tab
|
|
switch reflows the composition running inside it. */
|
|
.hf-ve-frame { position: relative; }
|
|
.hf-ve-cell { min-width: 0; }
|
|
.hf-ve-cell[data-on="false"] {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
visibility: hidden;
|
|
pointer-events: none;
|
|
}
|
|
.hf-ve-preview {
|
|
overflow: hidden;
|
|
border: 1px solid var(--ve-line);
|
|
border-radius: 12px;
|
|
}
|
|
/* CodeBlock carries the page margins (mt-5 mb-8) that separate it from prose,
|
|
which is dead space inside a tab. Element plus two classes out-specifies a
|
|
Tailwind utility without !important. */
|
|
.hf-ve-cell > div.code-block { margin: 0; }
|
|
/* The snippet wraps; the source does not.
|
|
A value the reader has to read in full should not hide half of itself off the
|
|
right edge, so the snippet pane wraps — what \`\`\`html wrap does for a fence.
|
|
The width reset is the half that matters: the block's own <code> is sized to
|
|
max-content, and content that never meets an edge never wraps.
|
|
Source is left to scroll sideways like every other code block on the site.
|
|
Wrapping it breaks its indentation, and a comment paragraph re-flowed to a
|
|
narrow column reads worse than one the reader can scroll. */
|
|
.hf-ve-snippet .shiki,
|
|
.hf-ve-snippet .shiki code {
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
/* A composition source runs to several hundred lines, and an un-capped tab
|
|
pushes the Customize panel off the screen. The cap goes on the scroll box
|
|
rather than the block, so the filename and its copy button stay put; and it
|
|
is a max-height, so a short source still hugs its own content and the dead
|
|
area under it stays gone. */
|
|
.hf-ve-cell .code-block pre {
|
|
max-height: 460px;
|
|
overflow: auto;
|
|
}
|
|
/* Four classes deep because the rule being answered is three
|
|
(\`html:not(.dark) .codeblock-light pre.shiki code\`), and a shorter selector
|
|
silently loses to it. */
|
|
.hf-ve .hf-ve-snippet .code-block pre.shiki code {
|
|
width: auto;
|
|
min-width: 0;
|
|
}
|
|
|
|
.hf-ve-panel {
|
|
margin-top: 12px;
|
|
border: 1px solid var(--ve-line);
|
|
border-radius: 12px;
|
|
}
|
|
.hf-ve-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 16px;
|
|
border-bottom: 1px solid var(--ve-line);
|
|
}
|
|
.hf-ve-title {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
color: var(--ve-muted);
|
|
}
|
|
.hf-ve-grid {
|
|
display: grid;
|
|
gap: 16px 28px;
|
|
padding: 16px;
|
|
}
|
|
@media (min-width: 640px) {
|
|
.hf-ve-grid { grid-template-columns: 1fr 1fr; }
|
|
}
|
|
.hf-ve-row {
|
|
display: flex;
|
|
align-items: baseline;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-bottom: 6px;
|
|
}
|
|
.hf-ve-label { font-size: 14px; font-weight: 500; color: var(--ve-fg); }
|
|
.hf-ve-value {
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
font-size: 12px;
|
|
font-variant-numeric: tabular-nums;
|
|
color: var(--ve-muted);
|
|
}
|
|
.hf-ve-desc {
|
|
margin: 6px 0 0;
|
|
font-size: 12px;
|
|
line-height: 1.45;
|
|
color: var(--ve-muted);
|
|
}
|
|
|
|
.hf-ve-btn {
|
|
padding: 4px 10px;
|
|
border: 1px solid transparent;
|
|
border-radius: 8px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
color: var(--ve-muted);
|
|
background: transparent;
|
|
}
|
|
.hf-ve-btn:hover:not(:disabled) { color: var(--ve-fg); background: var(--ve-hover); }
|
|
.hf-ve-btn:disabled { opacity: 0.4; cursor: default; }
|
|
|
|
.hf-ve-field {
|
|
width: 100%;
|
|
height: 36px;
|
|
padding: 0 12px;
|
|
border: 1px solid var(--ve-line);
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
line-height: 1.4;
|
|
color: var(--ve-fg);
|
|
background: var(--ve-surface);
|
|
}
|
|
.hf-ve-field::placeholder { color: var(--ve-muted); }
|
|
.hf-ve-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
|
|
|
|
/* Focus, once, for every control here. A ring outside the border rather than a
|
|
border colour alone: the border already carries the resting state, so
|
|
recolouring it is a change a reader can miss. Nothing shifts, because the
|
|
ring is a shadow. :focus-visible, so a pointer click does not light it up. */
|
|
.hf-ve-field:focus-visible,
|
|
.hf-ve-seg-btn:focus-visible,
|
|
.hf-ve-tab:focus-visible,
|
|
.hf-ve-btn:focus-visible,
|
|
.hf-ve-switch:focus-visible,
|
|
.hf-ve-swatch:focus-visible {
|
|
outline: none;
|
|
border-color: var(--ve-on-bg);
|
|
box-shadow: 0 0 0 3px var(--ve-ring);
|
|
}
|
|
/* A range is a track, and ringing the track rings a pill the width of the
|
|
panel. The thumb is the part that has focus, so the thumb is what says so. */
|
|
.hf-ve-range:focus-visible { outline: none; }
|
|
.hf-ve-range:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 4px var(--ve-ring); }
|
|
.hf-ve-range:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 4px var(--ve-ring); }
|
|
/* Safari still fires :focus for a click on a button, so the pair is kept. */
|
|
.hf-ve-field:focus { outline: none; border-color: var(--ve-on-bg); }
|
|
|
|
/* The enum branch above sends anything past four options here. No shipped item
|
|
does today (every enum in the registry has two to four), so this is styled to
|
|
the point of not looking foreign and no further — the chevron is one neutral
|
|
grey rather than a per-theme pair, because a data URI cannot read a token. */
|
|
.hf-ve-select {
|
|
appearance: none;
|
|
-webkit-appearance: none;
|
|
padding-right: 34px;
|
|
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none' stroke='%2389898f' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4 6 4 4 4-4'/%3E%3C/svg%3E");
|
|
background-repeat: no-repeat;
|
|
background-position: right 10px center;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.hf-ve-seg {
|
|
display: flex;
|
|
padding: 2px;
|
|
border: 1px solid var(--ve-line);
|
|
border-radius: 8px;
|
|
}
|
|
.hf-ve-seg-btn {
|
|
flex: 1;
|
|
padding: 4px 8px;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
color: var(--ve-muted);
|
|
background: transparent;
|
|
}
|
|
.hf-ve-seg-btn:hover:not([data-on="true"]) { color: var(--ve-fg); background: var(--ve-hover); }
|
|
.hf-ve-seg-btn[data-on="true"] { color: var(--ve-on-fg); background: var(--ve-on-bg); }
|
|
|
|
/* A range, rebuilt. \`accent-color\` alone leaves the platform's hairline track,
|
|
which reads as an unstyled browser part next to everything else here. Each
|
|
engine names its parts differently and shares none of them, so the same
|
|
track and thumb are written twice; a selector either engine cannot parse
|
|
drops the whole rule, which is why they are never grouped. */
|
|
.hf-ve-range {
|
|
width: 100%;
|
|
height: 20px;
|
|
appearance: none;
|
|
-webkit-appearance: none;
|
|
border: 0;
|
|
border-radius: 9999px;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
}
|
|
/* --ve-fill is set per render: painting progress on a native track means a
|
|
two-stop gradient, and only the component knows where the value sits. */
|
|
.hf-ve-range::-webkit-slider-runnable-track {
|
|
height: 6px;
|
|
border-radius: 9999px;
|
|
background: linear-gradient(
|
|
to right,
|
|
var(--ve-on-bg) var(--ve-fill, 0%),
|
|
var(--ve-line) var(--ve-fill, 0%)
|
|
);
|
|
}
|
|
.hf-ve-range::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
width: 16px;
|
|
height: 16px;
|
|
margin-top: -5px;
|
|
border: 2px solid var(--ve-on-bg);
|
|
border-radius: 9999px;
|
|
background: var(--ve-surface);
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.14);
|
|
}
|
|
.hf-ve-range::-moz-range-track { height: 6px; border-radius: 9999px; background: var(--ve-line); }
|
|
.hf-ve-range::-moz-range-progress { height: 6px; border-radius: 9999px; background: var(--ve-on-bg); }
|
|
.hf-ve-range::-moz-range-thumb {
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 2px solid var(--ve-on-bg);
|
|
border-radius: 9999px;
|
|
background: var(--ve-surface);
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.14);
|
|
}
|
|
/* Hover reads on the part being aimed at rather than on the whole strip: a
|
|
track that darkens under the pointer says "click me and the thumb comes
|
|
here", which is what a native range actually does. */
|
|
.hf-ve-range:hover::-webkit-slider-thumb { border-color: var(--ve-fg); }
|
|
.hf-ve-range:hover::-moz-range-thumb { border-color: var(--ve-fg); }
|
|
|
|
/* The number control, and the three things it used to leave unsaid.
|
|
*
|
|
* It could not say where the range ends, so a reader dragging \`stroke_width\`
|
|
* had no idea whether 12 was nearly nothing or nearly everything: the ends now
|
|
* carry min and max.
|
|
*
|
|
* It could not say where the author left the knob, which is the one reference
|
|
* point a panel built around deviating from the author's choice needs: a mark
|
|
* on the track is the default, and a double click puts the value back on it.
|
|
*
|
|
* And it printed the value in the label row, a fixed distance from a thumb that
|
|
* moves, so reading a drag meant looking in two places at once. The value rides
|
|
* the thumb instead.
|
|
*
|
|
* Still a real <input type="range">. Every custom slider on the web reimplements
|
|
* keyboard stepping, touch, and the screen-reader contract, and most of them do
|
|
* one of the three badly; none of what is added here needed the element
|
|
* replaced. Nothing moves that a finger is not moving: the value tracks the
|
|
* pointer because it is the pointer's own readout, and the only transition is
|
|
* the colour one every other control here shares. */
|
|
.hf-ve-slider {
|
|
display: grid;
|
|
gap: 1px;
|
|
}
|
|
.hf-ve-ruler {
|
|
position: relative;
|
|
height: 17px;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
font-size: 11px;
|
|
font-variant-numeric: tabular-nums;
|
|
line-height: 17px;
|
|
color: var(--ve-muted);
|
|
}
|
|
.hf-ve-bound {
|
|
position: absolute;
|
|
top: 0;
|
|
}
|
|
.hf-ve-bound[data-end="min"] { left: 0; }
|
|
.hf-ve-bound[data-end="max"] { right: 0; }
|
|
/* An end steps aside rather than being overprinted by the value arriving on
|
|
top of it. Visibility, not opacity: there is no fade here, the label is
|
|
either the thing being read or it is out of the way. */
|
|
.hf-ve-ruler[data-near="min"] .hf-ve-bound[data-end="min"],
|
|
.hf-ve-ruler[data-near="max"] .hf-ve-bound[data-end="max"] {
|
|
visibility: hidden;
|
|
}
|
|
/* translateX is centring, not motion: the readout is as wide as its own digits
|
|
and has to hang half of that either side of the thumb. */
|
|
.hf-ve-readout {
|
|
position: absolute;
|
|
top: 0;
|
|
left: calc(var(--ve-fill, 0%) + var(--ve-fill-nudge, 0px));
|
|
transform: translateX(-50%);
|
|
color: var(--ve-fg);
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
}
|
|
.hf-ve-track {
|
|
position: relative;
|
|
display: block;
|
|
}
|
|
.hf-ve-range { display: block; }
|
|
.hf-ve-default {
|
|
position: absolute;
|
|
top: 5px;
|
|
left: calc(var(--ve-default, 50%) + var(--ve-default-nudge, 0px));
|
|
width: 2px;
|
|
height: 10px;
|
|
margin-left: -1px;
|
|
border-radius: 1px;
|
|
background: var(--ve-muted);
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* A switch, for the boolean type. It used to fall through to the text input at
|
|
the end of control(), which asked the reader to type the word "true". */
|
|
.hf-ve-switch {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
width: 40px;
|
|
height: 24px;
|
|
padding: 2px;
|
|
border: 1px solid var(--ve-line);
|
|
border-radius: 9999px;
|
|
background: var(--ve-sunken);
|
|
cursor: pointer;
|
|
}
|
|
.hf-ve-switch[data-on="true"] { border-color: var(--ve-on-bg); background: var(--ve-on-bg); }
|
|
.hf-ve-switch-dot {
|
|
width: 18px;
|
|
height: 18px;
|
|
border-radius: 9999px;
|
|
background: var(--ve-surface);
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
|
|
}
|
|
.hf-ve-switch[data-on="true"] .hf-ve-switch-dot {
|
|
background: var(--ve-on-fg);
|
|
transform: translateX(16px);
|
|
}
|
|
|
|
/* The SVG import control: the same text field, with a way to fill it.
|
|
*
|
|
* The whole block is the drop target, not just the field, so a file let go over
|
|
* the button lands too. It says so by taking the same border colour a focused
|
|
* field takes, which is the one feedback channel this panel has left.
|
|
*
|
|
* A bare <input type="file"> is unlabelled, unstyleable and reads as "No file
|
|
* chosen" next to controls that carry their own value, so the real input is
|
|
* taken out of the layout and the tab order and a button in front of it is what
|
|
* a reader sees and what a keyboard reaches. Hidden with size and opacity
|
|
* rather than display:none, because an input that is not rendered at all is one
|
|
* some browsers decline to open a picker for. */
|
|
.hf-ve-drop {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
.hf-ve-drop[data-over="true"] .hf-ve-dropzone {
|
|
border-color: var(--ve-on-bg);
|
|
border-style: solid;
|
|
}
|
|
|
|
/* The import is the action almost everyone wants: a reader arrives with a
|
|
shape, not with path data. A dashed target reads as "put a file here" on
|
|
sight, where a button beneath a field of coordinates read as an afterthought
|
|
to the coordinates. */
|
|
.hf-ve-dropzone {
|
|
display: grid;
|
|
justify-items: center;
|
|
gap: 6px;
|
|
padding: 18px 12px;
|
|
border: 1px dashed var(--ve-line);
|
|
border-radius: 10px;
|
|
background: var(--ve-surface);
|
|
text-align: center;
|
|
}
|
|
.hf-ve-dropzone .hf-ve-btn {
|
|
padding: 7px 16px;
|
|
font-size: 13px;
|
|
color: var(--ve-fg);
|
|
border-color: var(--ve-line);
|
|
background: var(--ve-bg);
|
|
}
|
|
.hf-ve-dropzone .hf-ve-btn:hover:not(:disabled) { background: var(--ve-hover); }
|
|
|
|
/* Path data stays reachable, but a reader has to ask for it. A native details
|
|
element rather than our own toggle, so it opens with the keyboard and is
|
|
announced as expandable without any wiring. */
|
|
.hf-ve-advanced > summary {
|
|
font-size: 12px;
|
|
color: var(--ve-muted);
|
|
cursor: pointer;
|
|
list-style: none;
|
|
padding: 2px 0;
|
|
}
|
|
.hf-ve-advanced > summary::-webkit-details-marker { display: none; }
|
|
.hf-ve-advanced > summary::before { content: "▸ "; }
|
|
.hf-ve-advanced[open] > summary::before { content: "▾ "; }
|
|
.hf-ve-advanced > summary:hover { color: var(--ve-fg); }
|
|
.hf-ve-advanced .hf-ve-field { margin-top: 6px; }
|
|
.hf-ve-file {
|
|
position: absolute;
|
|
width: 1px;
|
|
height: 1px;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
.hf-ve-note {
|
|
margin: 0;
|
|
min-width: 0;
|
|
font-size: 12px;
|
|
line-height: 1.4;
|
|
color: var(--ve-muted);
|
|
}
|
|
.hf-ve-note[data-tone="error"] { color: var(--ve-danger); }
|
|
.hf-ve-note[data-tone="ok"] { color: var(--ve-fg); }
|
|
|
|
.hf-ve-swatch {
|
|
width: 40px;
|
|
height: 32px;
|
|
flex-shrink: 0;
|
|
padding: 2px;
|
|
border: 1px solid var(--ve-line);
|
|
border-radius: 8px;
|
|
background: var(--ve-surface);
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Nothing here moves.
|
|
*
|
|
* A press-down scale on every button and field, a scale-in on the tab panes, a
|
|
* springing switch knob and a growing slider thumb were all flourish: the
|
|
* control had already told you what it did by changing colour, and the motion
|
|
* was a second, slower answer to a question already settled. What is left is
|
|
* one colour transition, short enough to read as immediate and long enough not
|
|
* to flicker. A control here may change colour; it does not move.
|
|
*
|
|
* Which is also why there is no prefers-reduced-motion block any more. There is
|
|
* no motion left to reduce. */
|
|
.hf-ve-tint {
|
|
transition:
|
|
background-color 100ms ease,
|
|
border-color 100ms ease,
|
|
color 100ms ease;
|
|
}
|
|
`;
|
|
|
|
// >>> svg-import geometry
|
|
//
|
|
// Inside the component, and marked, for two reasons that pull the same way.
|
|
// Mintlify carries each exported binding into the page on its own, so a
|
|
// second `export const` beside this one is not in scope here: referencing it
|
|
// throws `is not defined` and takes the explorer down. And the geometry is
|
|
// the half of this feature that fails silently, so it has to be testable
|
|
// without a browser. `scripts/variables-explorer.test.ts` reads the source
|
|
// between these two markers and evaluates it, which is why they are here and
|
|
// why the names below are part of the contract.
|
|
/**
|
|
* Two of the registry's 663 declared variables hold raw SVG path data, and
|
|
* nobody authors `M 92 328 C 178 142 292 138 366 276 ...` by hand. Everything
|
|
* below is one control: pick or drop an SVG, and its geometry arrives in the
|
|
* field as path data the composition already knows how to draw.
|
|
*
|
|
* Why the default value decides, and not the variable's name
|
|
* ----------------------------------------------------------
|
|
* Half a dozen registry variables are called `path` and are enums of preset
|
|
* motion shapes (`sweep`, `bulb`). Keying the control off the name would put a
|
|
* file picker on all of them. The declared default is the honest signal: a
|
|
* value that starts with a moveto is path data and nothing else is, so the
|
|
* control lands on exactly `svg-stroke-trace.path` and
|
|
* `offset-path-traveler.path` today, and on any future path primitive without
|
|
* a list to maintain.
|
|
*
|
|
* Why the geometry is refitted rather than lifted
|
|
* -----------------------------------------------
|
|
* The `d` attribute of an imported file is written in that file's own viewBox.
|
|
* `svg-stroke-trace` draws in `0 0 1024 520`; a 24-unit icon lifted verbatim
|
|
* into it is a speck in the top-left corner, and a 2000-unit poster is clipped
|
|
* to a fragment. Either reads as a broken feature rather than a faithful one.
|
|
*
|
|
* So the import is scaled to fit, aspect preserved, centred. The box it is
|
|
* fitted into is the bounding box of the variable's own declared default, which
|
|
* is the one piece of authored geometry both this panel and the composition
|
|
* agree on. It needs no per-composition table and it keeps each primitive's
|
|
* intent: the stroke trace's default sits inset in its viewBox and an import
|
|
* lands inset the same way, while the traveler's default deliberately starts
|
|
* and ends off-frame, so an imported route enters and leaves frame too.
|
|
*
|
|
* There is no "keep original coordinates" escape hatch. A control that always
|
|
* produces something visible beats one that is faithful and blank; if the
|
|
* original numbers are wanted they can still be pasted into the same field,
|
|
* which is untouched and still the primary input.
|
|
*/
|
|
const isSvgPathData = (value) => typeof value === "string" && /^\s*[Mm]\s*-?[\d.]/.test(value);
|
|
|
|
/**
|
|
* Path data as a list of `{ code, args }`, commands and numbers separated.
|
|
*
|
|
* Hand-written because the browser exposes no path parser: `getPathData()` was
|
|
* never shipped and the `SVGPathSeg` API that preceded it was removed. What is
|
|
* being parsed is small and fully specified, and the two places the grammar
|
|
* bites are both handled here: arc flags may be written as bare adjacent digits
|
|
* (`a1 1 0 011 1`), and a command's arguments may repeat without repeating the
|
|
* letter, where a second `M` pair is a lineto rather than another moveto.
|
|
*
|
|
* Throws on anything it cannot read, which is what turns a corrupt file into a
|
|
* message in the panel instead of an invalid `d` in the preview.
|
|
*/
|
|
const parsePathData = (d) => {
|
|
const source = String(d);
|
|
const arity = { M: 2, L: 2, H: 1, V: 1, C: 6, S: 4, Q: 4, T: 2, A: 7, Z: 0 };
|
|
const commands = [];
|
|
let at = 0;
|
|
let code = "";
|
|
|
|
const separator = () => {
|
|
while (at < source.length && /[\s,]/.test(source[at])) at += 1;
|
|
};
|
|
const digits = () => {
|
|
while (at < source.length && source[at] >= "0" && source[at] <= "9") at += 1;
|
|
};
|
|
const number = () => {
|
|
separator();
|
|
const start = at;
|
|
if (source[at] === "+" || source[at] === "-") at += 1;
|
|
digits();
|
|
if (source[at] === ".") {
|
|
at += 1;
|
|
digits();
|
|
}
|
|
if (source[at] === "e" || source[at] === "E") {
|
|
at += 1;
|
|
if (source[at] === "+" || source[at] === "-") at += 1;
|
|
digits();
|
|
}
|
|
const text = source.slice(start, at);
|
|
const value = Number(text);
|
|
if (text === "" || !Number.isFinite(value)) {
|
|
throw new Error(`expected a number at character ${start + 1}`);
|
|
}
|
|
return value;
|
|
};
|
|
// A flag is one character, and the grammar allows it to touch the next one.
|
|
const flag = () => {
|
|
separator();
|
|
const character = source[at];
|
|
if (character !== "0" && character !== "1") {
|
|
throw new Error(`expected an arc flag at character ${at + 1}`);
|
|
}
|
|
at += 1;
|
|
return Number(character);
|
|
};
|
|
|
|
separator();
|
|
while (at < source.length) {
|
|
const character = source[at];
|
|
if (/[a-zA-Z]/.test(character)) {
|
|
// `arity[...] === undefined` rather than the `in` operator: Mintlify's
|
|
// snippet compiler walks this AST and has no handler for an `in`
|
|
// expression, and the throw it raises takes the whole page down.
|
|
if (arity[character.toUpperCase()] === undefined) {
|
|
throw new Error(`unknown command "${character}"`);
|
|
}
|
|
code = character;
|
|
at += 1;
|
|
} else if (code === "") {
|
|
throw new Error("path data must open with a command");
|
|
} else if (code === "M" || code === "m") {
|
|
// An implicit repeat of a moveto is a lineto, per the grammar.
|
|
code = code === "M" ? "L" : "l";
|
|
} else if (code === "Z" || code === "z") {
|
|
throw new Error(`expected a command at character ${at + 1}`);
|
|
}
|
|
|
|
const letter = code.toUpperCase();
|
|
const args = [];
|
|
if (letter === "A") {
|
|
args.push(number(), number(), number(), flag(), flag(), number(), number());
|
|
} else {
|
|
for (let taken = 0; taken < arity[letter]; taken += 1) args.push(number());
|
|
}
|
|
commands.push({ code, args });
|
|
separator();
|
|
}
|
|
|
|
if (commands.length === 0) throw new Error("path data is empty");
|
|
return commands;
|
|
};
|
|
|
|
/**
|
|
* The same path, as absolute `M` / `L` / `C` / `Q` / `Z` and nothing else.
|
|
*
|
|
* The point of the reduction is that every remaining argument list is a run of
|
|
* x/y pairs, so applying a matrix to any of them is the same three lines. The
|
|
* commands that are not pairs are the ones removed: `H` and `V` carry one
|
|
* coordinate, `A` carries radii and flags that a rotation or a mirror would
|
|
* silently invalidate, and `S` and `T` carry an implicit control point that is
|
|
* a reflection of the previous command's. Reflection is affine-covariant, so
|
|
* `S` would survive a transform on its own; it is resolved here anyway because
|
|
* an `A` in front of it becomes a cubic, and the reflection would then be taken
|
|
* from a control point the author never wrote.
|
|
*/
|
|
const normalisePathData = (commands) => {
|
|
const out = [];
|
|
let x = 0;
|
|
let y = 0;
|
|
let startX = 0;
|
|
let startY = 0;
|
|
let cubicControl = null;
|
|
let quadraticControl = null;
|
|
|
|
for (const { code, args } of commands) {
|
|
const letter = code.toUpperCase();
|
|
const relative = code !== letter;
|
|
const dx = relative ? x : 0;
|
|
const dy = relative ? y : 0;
|
|
const pairs = (values) => {
|
|
const mapped = [];
|
|
for (let index = 0; index + 1 < values.length; index += 2) {
|
|
mapped.push(values[index] + dx, values[index + 1] + dy);
|
|
}
|
|
return mapped;
|
|
};
|
|
let nextCubic = null;
|
|
let nextQuadratic = null;
|
|
|
|
if (letter === "M") {
|
|
const [px, py] = pairs(args);
|
|
out.push({ code: "M", args: [px, py] });
|
|
x = px;
|
|
y = py;
|
|
startX = px;
|
|
startY = py;
|
|
} else if (letter === "L") {
|
|
const [px, py] = pairs(args);
|
|
out.push({ code: "L", args: [px, py] });
|
|
x = px;
|
|
y = py;
|
|
} else if (letter === "H") {
|
|
x = args[0] + dx;
|
|
out.push({ code: "L", args: [x, y] });
|
|
} else if (letter === "V") {
|
|
y = args[0] + dy;
|
|
out.push({ code: "L", args: [x, y] });
|
|
} else if (letter === "C") {
|
|
const points = pairs(args);
|
|
out.push({ code: "C", args: points });
|
|
nextCubic = [points[2], points[3]];
|
|
x = points[4];
|
|
y = points[5];
|
|
} else if (letter === "S") {
|
|
const points = pairs(args);
|
|
const first = cubicControl ? [2 * x - cubicControl[0], 2 * y - cubicControl[1]] : [x, y];
|
|
out.push({ code: "C", args: [...first, ...points] });
|
|
nextCubic = [points[0], points[1]];
|
|
x = points[2];
|
|
y = points[3];
|
|
} else if (letter === "Q") {
|
|
const points = pairs(args);
|
|
out.push({ code: "Q", args: points });
|
|
nextQuadratic = [points[0], points[1]];
|
|
x = points[2];
|
|
y = points[3];
|
|
} else if (letter === "T") {
|
|
const points = pairs(args);
|
|
const control = quadraticControl
|
|
? [2 * x - quadraticControl[0], 2 * y - quadraticControl[1]]
|
|
: [x, y];
|
|
out.push({ code: "Q", args: [...control, ...points] });
|
|
nextQuadratic = control;
|
|
x = points[0];
|
|
y = points[1];
|
|
} else if (letter === "A") {
|
|
const endX = args[5] + dx;
|
|
const endY = args[6] + dy;
|
|
out.push(...arcToCubics(x, y, args[0], args[1], args[2], args[3], args[4], endX, endY));
|
|
x = endX;
|
|
y = endY;
|
|
} else if (letter === "Z") {
|
|
out.push({ code: "Z", args: [] });
|
|
x = startX;
|
|
y = startY;
|
|
}
|
|
|
|
cubicControl = nextCubic;
|
|
quadraticControl = nextQuadratic;
|
|
}
|
|
|
|
return out;
|
|
};
|
|
|
|
/**
|
|
* One elliptical arc as up to four cubics.
|
|
*
|
|
* The endpoint-to-centre conversion of the SVG implementation notes, then a
|
|
* quarter turn at a time, because a cubic tracks a circular arc well below 90
|
|
* degrees and visibly badly above it. The last segment's endpoint is restored
|
|
* to the authored one so a closed shape still closes exactly.
|
|
*/
|
|
const arcToCubics = (x1, y1, rx, ry, rotation, largeArc, sweep, x2, y2) => {
|
|
// Both degenerate cases are named by the spec: a zero radius draws a line,
|
|
// and coincident endpoints draw nothing at all.
|
|
if (x1 === x2 && y1 === y2) return [];
|
|
let radiusX = Math.abs(rx);
|
|
let radiusY = Math.abs(ry);
|
|
if (radiusX === 0 || radiusY === 0) return [{ code: "L", args: [x2, y2] }];
|
|
|
|
const phi = (rotation * Math.PI) / 180;
|
|
const cosPhi = Math.cos(phi);
|
|
const sinPhi = Math.sin(phi);
|
|
const midX = (x1 - x2) / 2;
|
|
const midY = (y1 - y2) / 2;
|
|
const primeX = cosPhi * midX + sinPhi * midY;
|
|
const primeY = -sinPhi * midX + cosPhi * midY;
|
|
|
|
// Radii too small to span the endpoints are scaled up until they just fit.
|
|
const oversize =
|
|
(primeX * primeX) / (radiusX * radiusX) + (primeY * primeY) / (radiusY * radiusY);
|
|
if (oversize > 1) {
|
|
const grow = Math.sqrt(oversize);
|
|
radiusX *= grow;
|
|
radiusY *= grow;
|
|
}
|
|
|
|
const denominator =
|
|
radiusX * radiusX * primeY * primeY + radiusY * radiusY * primeX * primeX;
|
|
const numerator =
|
|
radiusX * radiusX * radiusY * radiusY -
|
|
radiusX * radiusX * primeY * primeY -
|
|
radiusY * radiusY * primeX * primeX;
|
|
const factor =
|
|
(largeArc === sweep ? -1 : 1) * Math.sqrt(Math.max(0, numerator) / denominator);
|
|
const centrePrimeX = (factor * radiusX * primeY) / radiusY;
|
|
const centrePrimeY = (-factor * radiusY * primeX) / radiusX;
|
|
const centreX = cosPhi * centrePrimeX - sinPhi * centrePrimeY + (x1 + x2) / 2;
|
|
const centreY = sinPhi * centrePrimeX + cosPhi * centrePrimeY + (y1 + y2) / 2;
|
|
|
|
const angle = (ux, uy, vx, vy) => {
|
|
const length = Math.hypot(ux, uy) * Math.hypot(vx, vy);
|
|
const cosine = length === 0 ? 1 : Math.min(1, Math.max(-1, (ux * vx + uy * vy) / length));
|
|
return (ux * vy - uy * vx < 0 ? -1 : 1) * Math.acos(cosine);
|
|
};
|
|
const fromX = (primeX - centrePrimeX) / radiusX;
|
|
const fromY = (primeY - centrePrimeY) / radiusY;
|
|
const toX = (-primeX - centrePrimeX) / radiusX;
|
|
const toY = (-primeY - centrePrimeY) / radiusY;
|
|
const start = angle(1, 0, fromX, fromY);
|
|
let sweptAngle = angle(fromX, fromY, toX, toY);
|
|
if (!sweep && sweptAngle > 0) sweptAngle -= 2 * Math.PI;
|
|
if (sweep && sweptAngle < 0) sweptAngle += 2 * Math.PI;
|
|
|
|
const steps = Math.max(1, Math.ceil(Math.abs(sweptAngle) / (Math.PI / 2)));
|
|
const step = sweptAngle / steps;
|
|
const handle = (4 / 3) * Math.tan(step / 4);
|
|
const at = (t) => [
|
|
centreX + radiusX * Math.cos(t) * cosPhi - radiusY * Math.sin(t) * sinPhi,
|
|
centreY + radiusX * Math.cos(t) * sinPhi + radiusY * Math.sin(t) * cosPhi,
|
|
];
|
|
const slope = (t) => [
|
|
-radiusX * Math.sin(t) * cosPhi - radiusY * Math.cos(t) * sinPhi,
|
|
-radiusX * Math.sin(t) * sinPhi + radiusY * Math.cos(t) * cosPhi,
|
|
];
|
|
|
|
const out = [];
|
|
for (let index = 0; index < steps; index += 1) {
|
|
const from = start + index * step;
|
|
const to = from + step;
|
|
const [ax, ay] = at(from);
|
|
const [bx, by] = at(to);
|
|
const [aSlopeX, aSlopeY] = slope(from);
|
|
const [bSlopeX, bSlopeY] = slope(to);
|
|
out.push({
|
|
code: "C",
|
|
args: [
|
|
ax + handle * aSlopeX,
|
|
ay + handle * aSlopeY,
|
|
bx - handle * bSlopeX,
|
|
by - handle * bSlopeY,
|
|
bx,
|
|
by,
|
|
],
|
|
});
|
|
}
|
|
const last = out[out.length - 1];
|
|
last.args[4] = x2;
|
|
last.args[5] = y2;
|
|
return out;
|
|
};
|
|
|
|
/**
|
|
* Every point through a matrix. Only correct on the output of
|
|
* `normalisePathData`, where every argument list is a run of x/y pairs.
|
|
*/
|
|
const transformPathData = (segments, matrix) =>
|
|
segments.map(({ code, args }) => {
|
|
const moved = [];
|
|
for (let index = 0; index + 1 < args.length; index += 2) {
|
|
const x = args[index];
|
|
const y = args[index + 1];
|
|
moved.push(matrix.a * x + matrix.c * y + matrix.e, matrix.b * x + matrix.d * y + matrix.f);
|
|
}
|
|
return { code, args: moved };
|
|
});
|
|
|
|
/**
|
|
* Scale to fit, aspect preserved, centred: `source` placed inside `target`.
|
|
*
|
|
* A dimension the source does not have is not a constraint on it, so a flat
|
|
* horizontal route is sized by its width alone rather than collapsing to a
|
|
* scale of zero.
|
|
*/
|
|
const fitMatrix = (source, target) => {
|
|
// A dimension the source does not have is not a constraint on it: a flat
|
|
// horizontal route divides by zero, which is Infinity and loses the `min`
|
|
// to the dimension that is real. Guarding that explicitly was a branch that
|
|
// could not be told apart from this one, so it is not here.
|
|
const chosen = Math.min(target.width / source.width, target.height / source.height);
|
|
const scale = Number.isFinite(chosen) && chosen > 0 ? chosen : 1;
|
|
return {
|
|
a: scale,
|
|
b: 0,
|
|
c: 0,
|
|
d: scale,
|
|
e: target.x + target.width / 2 - (source.x + source.width / 2) * scale,
|
|
f: target.y + target.height / 2 - (source.y + source.height / 2) * scale,
|
|
};
|
|
};
|
|
|
|
/** Back to a `d` string. Two decimals is a hundredth of a viewBox unit. */
|
|
const printPathData = (segments) =>
|
|
segments
|
|
.map(({ code, args }) => {
|
|
if (args.length === 0) return code;
|
|
const numbers = args.map((value) => {
|
|
const rounded = Math.round(value * 100) / 100;
|
|
return String(Object.is(rounded, -0) ? 0 : rounded);
|
|
});
|
|
return `${code} ${numbers.join(" ")}`;
|
|
})
|
|
.join(" ");
|
|
|
|
/**
|
|
* The five shapes that are not a `<path>`, as path data.
|
|
*
|
|
* Rejecting them was the alternative, and it would reject most real files: a
|
|
* logo exported from a drawing tool is routinely a `<rect>` and two `<circle>`
|
|
* elements. Each conversion is the geometry the SVG shape module defines, and
|
|
* the curved ones are written with arcs so that `normalisePathData` reduces
|
|
* them by the same route an authored arc takes.
|
|
*
|
|
* Takes attributes rather than an element so it can be tested without a DOM.
|
|
* Returns null for a shape with no area to draw, which the caller counts as one
|
|
* more element it could not use.
|
|
*/
|
|
const shapePathData = (tag, attrs) => {
|
|
const number = (name, fallback = 0) => {
|
|
const value = parseFloat(attrs[name]);
|
|
return Number.isFinite(value) ? value : fallback;
|
|
};
|
|
|
|
if (tag === "path") {
|
|
const d = typeof attrs.d === "string" ? attrs.d.trim() : "";
|
|
return d === "" ? null : d;
|
|
}
|
|
|
|
if (tag === "rect") {
|
|
const width = number("width");
|
|
const height = number("height");
|
|
if (!(width > 0) || !(height > 0)) return null;
|
|
const x = number("x");
|
|
const y = number("y");
|
|
// Either radius alone defines both, which is what the shape module says and
|
|
// what a rounded rect exported with only `rx` relies on.
|
|
const declaredX = parseFloat(attrs.rx);
|
|
const declaredY = parseFloat(attrs.ry);
|
|
const rawX = Number.isFinite(declaredX) ? declaredX : declaredY;
|
|
const rawY = Number.isFinite(declaredY) ? declaredY : declaredX;
|
|
const rx = Math.min(Math.max(Number.isFinite(rawX) ? rawX : 0, 0), width / 2);
|
|
const ry = Math.min(Math.max(Number.isFinite(rawY) ? rawY : 0, 0), height / 2);
|
|
if (rx === 0 || ry === 0) {
|
|
return `M ${x} ${y} H ${x + width} V ${y + height} H ${x} Z`;
|
|
}
|
|
return [
|
|
`M ${x + rx} ${y}`,
|
|
`H ${x + width - rx}`,
|
|
`A ${rx} ${ry} 0 0 1 ${x + width} ${y + ry}`,
|
|
`V ${y + height - ry}`,
|
|
`A ${rx} ${ry} 0 0 1 ${x + width - rx} ${y + height}`,
|
|
`H ${x + rx}`,
|
|
`A ${rx} ${ry} 0 0 1 ${x} ${y + height - ry}`,
|
|
`V ${y + ry}`,
|
|
`A ${rx} ${ry} 0 0 1 ${x + rx} ${y}`,
|
|
"Z",
|
|
].join(" ");
|
|
}
|
|
|
|
if (tag === "circle" || tag === "ellipse") {
|
|
const rx = tag === "circle" ? number("r") : number("rx");
|
|
const ry = tag === "circle" ? number("r") : number("ry");
|
|
if (!(rx > 0) || !(ry > 0)) return null;
|
|
const cx = number("cx");
|
|
const cy = number("cy");
|
|
// Two half turns: one arc cannot describe a full ellipse, because its start
|
|
// and end points would coincide and the spec draws nothing.
|
|
return [
|
|
`M ${cx - rx} ${cy}`,
|
|
`A ${rx} ${ry} 0 1 0 ${cx + rx} ${cy}`,
|
|
`A ${rx} ${ry} 0 1 0 ${cx - rx} ${cy}`,
|
|
"Z",
|
|
].join(" ");
|
|
}
|
|
|
|
if (tag === "line") {
|
|
const x1 = number("x1");
|
|
const y1 = number("y1");
|
|
const x2 = number("x2");
|
|
const y2 = number("y2");
|
|
if (x1 === x2 && y1 === y2) return null;
|
|
return `M ${x1} ${y1} L ${x2} ${y2}`;
|
|
}
|
|
|
|
if (tag === "polyline" || tag === "polygon") {
|
|
const values = String(attrs.points ?? "")
|
|
.trim()
|
|
.split(/[\s,]+/)
|
|
.map(Number)
|
|
.filter((value) => Number.isFinite(value));
|
|
if (values.length < 4) return null;
|
|
const steps = [`M ${values[0]} ${values[1]}`];
|
|
for (let index = 2; index + 1 < values.length; index += 2) {
|
|
steps.push(`L ${values[index]} ${values[index + 1]}`);
|
|
}
|
|
if (tag === "polygon") steps.push("Z");
|
|
return steps.join(" ");
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* An SVG file's text, in: path data fitted to `target`, out.
|
|
*
|
|
* The measuring is done by the browser rather than by more arithmetic here.
|
|
* `getScreenCTM` resolves the whole `transform` chain of an element, including
|
|
* every group above it and the root's own viewBox, which is the difference
|
|
* between a logo that sits where its author put it and one whose halves fly
|
|
* apart. It is read relative to an empty `<g>` appended to the root, so the
|
|
* result is the file's own user space rather than screen pixels, and whatever
|
|
* size the offscreen host happens to have cancels out.
|
|
*
|
|
* Both of those need layout, which is why the file is parked in the document
|
|
* instead of measured in the detached tree `DOMParser` returns. It is removed
|
|
* again in a `finally`, so a throw does not leave it there.
|
|
*
|
|
* Every failure is a throw carrying the sentence the panel shows. Importing
|
|
* nothing quietly is the one outcome that is not allowed: it looks identical to
|
|
* a control that does not work.
|
|
*/
|
|
const svgToPathData = (svgText, targetPathData, doc = document) => {
|
|
const NS = "http://www.w3.org/2000/svg";
|
|
const parsed = new DOMParser().parseFromString(String(svgText), "image/svg+xml");
|
|
if (
|
|
parsed.getElementsByTagName("parsererror").length > 0 ||
|
|
!parsed.documentElement ||
|
|
parsed.documentElement.localName !== "svg"
|
|
) {
|
|
throw new Error("That file is not an SVG, or its markup is malformed.");
|
|
}
|
|
|
|
const host = doc.createElement("div");
|
|
host.setAttribute("aria-hidden", "true");
|
|
host.style.cssText =
|
|
"position:fixed;left:-99999px;top:0;width:600px;height:600px;overflow:hidden;";
|
|
const svg = doc.importNode(parsed.documentElement, true);
|
|
host.appendChild(svg);
|
|
doc.body.appendChild(host);
|
|
|
|
try {
|
|
const reference = doc.createElementNS(NS, "g");
|
|
svg.appendChild(reference);
|
|
const rootMatrix = reference.getScreenCTM();
|
|
|
|
// Anything inside these is a definition, not a drawing. Matched by
|
|
// localName rather than a selector because a document that came from
|
|
// `image/svg+xml` matches `clipPath` case-sensitively and an HTML one
|
|
// does not.
|
|
const defining = ["defs", "clipPath", "mask", "symbol", "marker", "pattern"];
|
|
const isDefinition = (element) => {
|
|
for (let node = element.parentNode; node && node !== svg; node = node.parentNode) {
|
|
if (defining.includes(node.localName)) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const shapes = [...svg.querySelectorAll("path,rect,circle,ellipse,line,polyline,polygon")];
|
|
const segments = [];
|
|
let shapesUsed = 0;
|
|
let firstProblem = null;
|
|
|
|
for (const element of shapes) {
|
|
if (isDefinition(element)) continue;
|
|
if (doc.defaultView.getComputedStyle(element).display === "none") continue;
|
|
const attrs = {};
|
|
for (const attribute of element.attributes) attrs[attribute.localName] = attribute.value;
|
|
const d = shapePathData(element.localName, attrs);
|
|
if (d === null) continue;
|
|
let own;
|
|
try {
|
|
own = normalisePathData(parsePathData(d));
|
|
} catch (error) {
|
|
firstProblem = firstProblem ?? error.message;
|
|
continue;
|
|
}
|
|
const matrix = element.getScreenCTM();
|
|
segments.push(
|
|
...(rootMatrix && matrix
|
|
? transformPathData(own, rootMatrix.inverse().multiply(matrix))
|
|
: own),
|
|
);
|
|
shapesUsed += 1;
|
|
}
|
|
|
|
if (segments.length === 0) {
|
|
if (firstProblem) throw new Error(`This SVG has unreadable path data: ${firstProblem}.`);
|
|
const untraceable = ["text", "image", "use"].find(
|
|
(tag) => svg.getElementsByTagName(tag).length > 0,
|
|
);
|
|
throw new Error(
|
|
untraceable
|
|
? `This SVG draws with <${untraceable}>, which has no outline to trace. Convert it to paths and try again.`
|
|
: "This SVG has no shapes to import.",
|
|
);
|
|
}
|
|
|
|
// Subpaths in document order, one after another. A stroke trace draws them
|
|
// in that order and the moveto between two of them is a gap rather than a
|
|
// line, so nothing is joined that the file did not join.
|
|
const probe = doc.createElementNS(NS, "path");
|
|
svg.appendChild(probe);
|
|
probe.setAttribute("d", printPathData(segments));
|
|
const source = probe.getBBox();
|
|
if (!(source.width > 0) || !(source.height > 0)) {
|
|
if (!(source.width > 0) && !(source.height > 0)) {
|
|
throw new Error("This SVG's shapes have no size.");
|
|
}
|
|
}
|
|
probe.setAttribute("d", String(targetPathData));
|
|
const target = probe.getBBox();
|
|
|
|
return {
|
|
d: printPathData(transformPathData(segments, fitMatrix(source, target))),
|
|
shapes: shapesUsed,
|
|
};
|
|
} finally {
|
|
host.remove();
|
|
}
|
|
};
|
|
// <<< svg-import geometry
|
|
|
|
/**
|
|
* How finely a number was written, as a step: 0.06 gives 0.01, 100 gives 1.
|
|
*
|
|
* Only for the number variables that declare no step of their own. A default
|
|
* is the one sample of the variable's scale that exists, and the precision it
|
|
* was written to is the author saying what a meaningful change to it is.
|
|
*/
|
|
const granularity = (value) => {
|
|
const decimals = String(value).split(".")[1];
|
|
return decimals ? Number(`1e-${decimals.length}`) : 1;
|
|
};
|
|
|
|
/**
|
|
* The value box on the right of a label. Empty where the control already
|
|
* shows the value in full — a text input repeating itself is noise.
|
|
*/
|
|
const readout = (variable, value) => {
|
|
// Numbers used to print here. They now ride their own thumb, or sit in a
|
|
// number field, and a second copy a fixed distance away was the thing that
|
|
// made a reader look in two places at once.
|
|
if (variable.type === "number") return "";
|
|
if (variable.type === "color") return String(value);
|
|
if (variable.type === "enum") {
|
|
const hit = (variable.options ?? []).find((o) => o.value === value);
|
|
return hit ? (hit.label ?? hit.value) : String(value);
|
|
}
|
|
return "";
|
|
};
|
|
|
|
/**
|
|
* The control for one variable.
|
|
*
|
|
* A plain function called as `{control(...)}`, not a component. Mintlify
|
|
* compiles a snippet as MDX, where a capitalised tag has to be one the *page*
|
|
* provides — `<CodeBlock />` above is, `<Control />` would not be, and a tag
|
|
* that resolves to nothing takes the whole explorer down with it.
|
|
*
|
|
* Which is also why the SVG import's message is state passed in rather than
|
|
* state of its own: `useState` here would be a hook called outside a
|
|
* component, and the panel keeps one note per variable instead.
|
|
*/
|
|
const control = (variable, value, onChange, note, onNote, onTyping) => {
|
|
const options = variable.options ?? [];
|
|
|
|
// Up to four options fit a segmented row at docs width. Past that the
|
|
// segments shrink out of readability and a select is the honest control.
|
|
if (variable.type === "enum" && options.length > 0 && options.length <= 4) {
|
|
return (
|
|
<div className="hf-ve-seg">
|
|
{options.map((o) => (
|
|
<button
|
|
key={o.value}
|
|
type="button"
|
|
data-on={value === o.value}
|
|
aria-pressed={value === o.value}
|
|
onClick={() => onChange(o.value)}
|
|
className="hf-ve-seg-btn hf-ve-tint"
|
|
>
|
|
{o.label ?? o.value}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (variable.type === "enum") {
|
|
return (
|
|
<select
|
|
className="hf-ve-field hf-ve-select hf-ve-tint"
|
|
value={value}
|
|
aria-label={variable.label ?? variable.id}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
>
|
|
{options.map((o) => (
|
|
<option key={o.value} value={o.value}>
|
|
{o.label ?? o.value}
|
|
</option>
|
|
))}
|
|
</select>
|
|
);
|
|
}
|
|
|
|
if (variable.type === "number") {
|
|
const min = Number(variable.min);
|
|
const max = Number(variable.max);
|
|
const unit = variable.unit ?? "";
|
|
const declaredStep = Number(variable.step);
|
|
const step = declaredStep > 0 ? declaredStep : 1;
|
|
const label = variable.label ?? variable.id;
|
|
|
|
// 16 of the registry's 112 number variables declare no bounds, and a
|
|
// range input with no min/max is 0 to 100 by definition. That is not a
|
|
// mispainted slider, it is a false one: `cursor-glyph-trail.fade`
|
|
// defaults to 0.8, and on a 0-to-100 integer scale a reader who touches
|
|
// it can never get 0.8 back. `count-up.end` defaults to 100 and sits
|
|
// pinned at a maximum that is not its maximum.
|
|
//
|
|
// So an unbounded variable gets the control that can say what it is: a
|
|
// number field, where the authored value is expressible and no invented
|
|
// range is implied. Guessing bounds from the default was the other
|
|
// option, and `count-up.end` is exactly the case that shows why not,
|
|
// since 100 is as plausibly a tenth of the range as all of it. Declaring
|
|
// min and max on the registry item promotes it to a slider with no code
|
|
// change here, which is the fix that belongs upstream.
|
|
if (!(Number.isFinite(min) && Number.isFinite(max) && max > min)) {
|
|
return (
|
|
<input
|
|
type="number"
|
|
className="hf-ve-field hf-ve-mono hf-ve-tint"
|
|
value={value}
|
|
min={Number.isFinite(min) ? min : undefined}
|
|
max={Number.isFinite(max) ? max : undefined}
|
|
// How finely the author wrote the default is how finely it steps.
|
|
// The alternatives are both wrong here: step 1 rejects the 0.06 and
|
|
// 0.45 defaults several of these ship with, and "any" accepts them
|
|
// but makes the spinner and the arrow keys move by a whole 1, which
|
|
// takes `fade` from 0.8 to 1.8 in one press.
|
|
step={declaredStep > 0 ? declaredStep : granularity(variable.default)}
|
|
aria-label={label}
|
|
// ponytail: a value that does not parse is dropped rather than
|
|
// written, so the preview never reloads on a half-typed number.
|
|
// The cost is that the field cannot be emptied; every partial
|
|
// number that matters ("0", "0.", "0.4") parses, so it is typeable.
|
|
onChange={(e) => {
|
|
const next = Number(e.target.value);
|
|
if (e.target.value !== "" && Number.isFinite(next)) onChange(next);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const at = (n) => Math.min(1, Math.max(0, (Number(n) - min) / (max - min)));
|
|
const now = at(value);
|
|
const authored = at(variable.default);
|
|
// The thumb centre is inset by half its own width at each end, so a
|
|
// percentage alone drifts up to 8px away from it. Both the readout and
|
|
// the default mark are placed with the same correction, which is what
|
|
// keeps them agreeing with the thumb rather than nearly agreeing.
|
|
const place = (fraction) => ({
|
|
offset: `${fraction * 100}%`,
|
|
nudge: `${(0.5 - fraction) * 16}px`,
|
|
});
|
|
const value_ = place(now);
|
|
const default_ = place(authored);
|
|
|
|
// Shift is read once, when the gesture starts, and written straight to
|
|
// the DOM. React re-renders during a drag do not undo it, because the
|
|
// `step` prop itself never changes and React only writes attributes it
|
|
// sees change. A `useState` here would mean a global key listener and a
|
|
// re-render of the whole panel on every shift press.
|
|
const grain = (event) => {
|
|
event.currentTarget.step = event.shiftKey ? step / 10 : step;
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="hf-ve-slider"
|
|
style={{
|
|
"--ve-fill": value_.offset,
|
|
"--ve-fill-nudge": value_.nudge,
|
|
"--ve-default": default_.offset,
|
|
"--ve-default-nudge": default_.nudge,
|
|
}}
|
|
>
|
|
{/* The bounds, and the live value between them.
|
|
The value used to sit in the label row, a fixed distance from a
|
|
thumb that moves; here it rides the thumb, so reading it costs no
|
|
eye movement mid-drag. The ends carry min and max, which the panel
|
|
never showed at all: a track alone cannot say whether 12 is nearly
|
|
nothing or nearly everything. Each end hides itself when the value
|
|
arrives on top of it, so the two never overprint. */}
|
|
<div
|
|
className="hf-ve-ruler"
|
|
data-near={now < 0.14 ? "min" : now > 0.86 ? "max" : ""}
|
|
aria-hidden="true"
|
|
>
|
|
<span className="hf-ve-bound" data-end="min">
|
|
{min}
|
|
</span>
|
|
<span className="hf-ve-bound" data-end="max">
|
|
{max}
|
|
</span>
|
|
<span className="hf-ve-readout">
|
|
{value}
|
|
{unit}
|
|
</span>
|
|
</div>
|
|
<span className="hf-ve-track">
|
|
<input
|
|
type="range"
|
|
className="hf-ve-range"
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
value={value}
|
|
aria-label={label}
|
|
// The unit is on screen but not in the accessible name, and a
|
|
// bare "12" is not what this control is worth saying.
|
|
aria-valuetext={`${value}${unit}`}
|
|
onChange={(e) => onChange(Number(e.target.value))}
|
|
onPointerDown={grain}
|
|
onKeyDown={grain}
|
|
// Back to the author's value, the gesture every audio and motion
|
|
// tool uses for it. Reset above does the same for the whole
|
|
// panel; this is the one knob you have just pushed too far.
|
|
onDoubleClick={() => onChange(variable.default)}
|
|
/>
|
|
{/* Where the author left it. The panel is an invitation to deviate
|
|
from that, and without the mark there is nothing to deviate
|
|
from. Absent while the thumb is still on top of it, because a
|
|
mark under the thumb marks nothing and reads as a seam in it.
|
|
Four percent of the track is about one thumb at every width
|
|
this panel is laid out at. */}
|
|
{Math.abs(now - authored) > 0.04 && (
|
|
<span className="hf-ve-default" aria-hidden="true" />
|
|
)}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (variable.type === "boolean") {
|
|
// A real switch. Without this branch a boolean fell through to the text
|
|
// input at the end of this function, which asked the reader to type the
|
|
// word "true" — and typed anything else silently became a string.
|
|
const on = value === true || value === "true";
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={on}
|
|
aria-label={variable.label ?? variable.id}
|
|
data-on={on}
|
|
onClick={() => onChange(!on)}
|
|
className="hf-ve-switch"
|
|
>
|
|
<span className="hf-ve-switch-dot" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
if (variable.type === "color") {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="color"
|
|
className="hf-ve-swatch hf-ve-tint"
|
|
value={value}
|
|
aria-label={variable.label ?? variable.id}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
/>
|
|
<input
|
|
type="text"
|
|
className="hf-ve-field hf-ve-mono hf-ve-tint"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
onFocus={() => onTyping(variable.id)}
|
|
onBlur={() => onTyping(null)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") e.currentTarget.blur();
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// A string variable whose default is path data. See `isSvgPathData` at the
|
|
// foot of this file for why the default decides and the name does not.
|
|
if (isSvgPathData(variable.default)) {
|
|
const fileId = `hf-ve-file-${variable.id}`;
|
|
const noteId = `hf-ve-note-${variable.id}`;
|
|
const receive = (file) => {
|
|
if (!file) return;
|
|
file
|
|
.text()
|
|
.then((text) => {
|
|
const { d, shapes } = svgToPathData(text, variable.default);
|
|
onChange(d);
|
|
onNote({
|
|
tone: "ok",
|
|
message: `${file.name}: ${shapes} shape${shapes === 1 ? "" : "s"} scaled to fit.`,
|
|
});
|
|
})
|
|
.catch((error) => onNote({ tone: "error", message: error.message }));
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="hf-ve-drop"
|
|
onDragOver={(event) => {
|
|
event.preventDefault();
|
|
event.currentTarget.dataset.over = "true";
|
|
}}
|
|
onDragLeave={(event) => {
|
|
event.currentTarget.dataset.over = "false";
|
|
}}
|
|
onDrop={(event) => {
|
|
event.preventDefault();
|
|
event.currentTarget.dataset.over = "false";
|
|
receive(event.dataTransfer.files[0]);
|
|
}}
|
|
>
|
|
{/* The button is the trigger, so the picker is reachable by tab and
|
|
Enter and carries the focus ring every other control here has.
|
|
Dropping a file does the same thing and is never the only way. */}
|
|
<div className="hf-ve-dropzone">
|
|
<button
|
|
type="button"
|
|
className="hf-ve-btn hf-ve-tint"
|
|
onClick={() => document.getElementById(fileId).click()}
|
|
>
|
|
Import SVG
|
|
</button>
|
|
<input
|
|
id={fileId}
|
|
type="file"
|
|
accept=".svg,image/svg+xml"
|
|
className="hf-ve-file"
|
|
tabIndex={-1}
|
|
aria-hidden="true"
|
|
onChange={(event) => {
|
|
receive(event.target.files[0]);
|
|
// Cleared so picking the same file twice is two events.
|
|
event.target.value = "";
|
|
}}
|
|
/>
|
|
{/* Always present, so the region is one a screen reader is
|
|
already watching when a message arrives. */}
|
|
<p id={noteId} role="status" className="hf-ve-note" data-tone={note ? note.tone : ""}>
|
|
{note ? note.message : "Or drop one here. Scaled to fit and centred."}
|
|
</p>
|
|
</div>
|
|
<details className="hf-ve-advanced">
|
|
<summary>Path data</summary>
|
|
<input
|
|
type="text"
|
|
className="hf-ve-field hf-ve-mono hf-ve-tint"
|
|
value={value}
|
|
aria-label={variable.label ?? variable.id}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
onFocus={() => onTyping(variable.id)}
|
|
onBlur={() => onTyping(null)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") e.currentTarget.blur();
|
|
}}
|
|
/>
|
|
</details>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<input
|
|
type="text"
|
|
className="hf-ve-field hf-ve-tint"
|
|
value={value}
|
|
aria-label={variable.label ?? variable.id}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
onFocus={() => onTyping(variable.id)}
|
|
onBlur={() => onTyping(null)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") e.currentTarget.blur();
|
|
}}
|
|
/>
|
|
);
|
|
};
|
|
|
|
// Keyed on the declarations' content, not their identity: MDX hands this
|
|
// component a fresh `variables` array on every render, so memoising on the
|
|
// array itself would rebuild the defaults every time and defeat the point.
|
|
const variablesKey = JSON.stringify(variables);
|
|
const defaults = useMemo(() => {
|
|
const built = {};
|
|
for (const v of variables) if (v.default !== undefined) built[v.id] = v.default;
|
|
return built;
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [variablesKey]);
|
|
|
|
/**
|
|
* Values survive a reload by living in the query string.
|
|
*
|
|
* Scoped by composition id, so two links to different items never read each
|
|
* other's settings, and only the values that differ from the defaults are
|
|
* written — a reader who changed one knob gets a short, obvious URL rather
|
|
* than every variable spelled out.
|
|
*
|
|
* Anything unreadable is ignored rather than thrown: a truncated or
|
|
* hand-edited URL should open the piece at its defaults, not break the page.
|
|
*/
|
|
const urlKey = `vars-${compositionId}`;
|
|
|
|
const readFromUrl = () => {
|
|
if (typeof window === "undefined") return {};
|
|
try {
|
|
const raw = new URLSearchParams(window.location.search).get(urlKey);
|
|
if (!raw) return {};
|
|
// `URLSearchParams` has already decoded this once. Decoding again turned
|
|
// a path full of percent-escapes into something that no longer parsed,
|
|
// and doubled the length of every link.
|
|
const parsed = JSON.parse(raw);
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
// Only ids this item declares; a stale link must not inject stray keys.
|
|
const declared = new Set(variables.map((v) => v.id));
|
|
return Object.fromEntries(Object.entries(parsed).filter(([id]) => declared.has(id)));
|
|
} catch {
|
|
return {};
|
|
}
|
|
};
|
|
|
|
const [values, setValues] = useState(() => ({ ...defaults, ...readFromUrl() }));
|
|
|
|
/**
|
|
* Read the URL again once the component is actually in a browser.
|
|
*
|
|
* The first render happens on the server, where there is no `window`, so the
|
|
* state above can only be the declared defaults. React then hydrates against
|
|
* that markup and never revisits it — which is why a shared link opened at
|
|
* its defaults and only looked right if you touched a control. Applying the
|
|
* values after mount is what makes a reload land where it left off.
|
|
*
|
|
* Runs once. Later edits own the state from then on, and the effect below
|
|
* keeps the URL in step with them.
|
|
*/
|
|
useEffect(() => {
|
|
const fromUrl = readFromUrl();
|
|
if (Object.keys(fromUrl).length > 0) setValues((current) => ({ ...current, ...fromUrl }));
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// `replaceState` rather than `pushState`: dragging a slider should not stack
|
|
// up history entries the back button has to walk through.
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
const changed = Object.fromEntries(
|
|
Object.entries(values).filter(([id, value]) => JSON.stringify(value) !== JSON.stringify(defaults[id])),
|
|
);
|
|
const url = new URL(window.location.href);
|
|
if (Object.keys(changed).length === 0) url.searchParams.delete(urlKey);
|
|
else url.searchParams.set(urlKey, JSON.stringify(changed));
|
|
|
|
// `defaults` is rebuilt every render, so this effect runs every render too.
|
|
// Comparing first keeps it to an actual change rather than touching the
|
|
// history API on each one.
|
|
const next = url.toString();
|
|
if (next !== window.location.href) {
|
|
window.history.replaceState(null, "", next);
|
|
// `replaceState` fires no event, and the install command on the same page
|
|
// needs to follow these values.
|
|
window.dispatchEvent(new CustomEvent("hf-vars-changed"));
|
|
}
|
|
}, [values, defaults, urlKey]);
|
|
// What the SVG import last had to say, per variable. Held here because
|
|
// `control` is a function rather than a component and cannot hold it itself.
|
|
const [notes, setNotes] = useState({});
|
|
|
|
/**
|
|
* The id of the text field being typed into, if any.
|
|
*
|
|
* Every other control reports a whole value on every event: a slider at any
|
|
* position is a position, a swatch is a colour. A text field is not. While
|
|
* someone types `v3`, `v` is a prefix, and the preview remounted on it and
|
|
* showed them a composition built from half a word.
|
|
*
|
|
* So the post below waits while a text field has focus, and goes out when the
|
|
* edit is committed — Enter, or clicking away. The field itself never lags;
|
|
* only the mount waits, and it never sees a state nobody asked for.
|
|
*/
|
|
const [typing, setTyping] = useState(null);
|
|
|
|
/** What was last posted, so ending an edit that changed nothing is free. */
|
|
const posted = useRef(null);
|
|
const [tab, setTab] = useState("preview");
|
|
const frame = useRef(null);
|
|
|
|
// The frame is a document we write, not a file we fetch.
|
|
//
|
|
// The preview used to be an `.html` sitting in `docs/public`, which the docs
|
|
// host does not publish — it 404'd in production and showed an empty panel.
|
|
// The composition now arrives as JSON and is mounted here, so the only thing
|
|
// that has to survive the deploy is the payload, which is a servable type.
|
|
//
|
|
// Values are injected as `window.__hfVariables` into the composition's own
|
|
// head before any of its scripts run. That is where the runtime reads render
|
|
// overrides from, and doing it in the markup rather than after load is what
|
|
// guarantees the composition never initialises with the wrong values first.
|
|
const bootstrap = [
|
|
"<!doctype html><html><head><meta charset='utf-8'>",
|
|
"<style>html,body{margin:0;height:100%;overflow:hidden;background:transparent}",
|
|
"hyperframes-player{display:block;width:100%;height:100%}</style>",
|
|
// `latest`, not a pinned line. A pin here is a number nothing reads back:
|
|
// the panel keeps working on whatever build the pin names, so a stale one
|
|
// is invisible until someone wonders why a shipped fix never arrived.
|
|
// `scripts/player-cdn-pin.test.ts` fails if a pin comes back.
|
|
'<script src="https://cdn.jsdelivr.net/npm/@hyperframes/player@latest/dist/hyperframes-player.global.js"></' +
|
|
"script>",
|
|
"</head><body><script>",
|
|
"(function(){",
|
|
` var PAYLOAD = ${JSON.stringify(previewSrc)};`,
|
|
// The frame mounts with whatever a shared link asked for, not the bare
|
|
// defaults. Posting the values afterwards is too late for anything the
|
|
// composition reads once at init — a path arrives, the mark is already
|
|
// drawn from the default one, and only a later edit corrects it.
|
|
` var INITIAL = ${JSON.stringify({ ...defaults, ...readFromUrl() })};`,
|
|
" var html = null, player = null, poll = null;",
|
|
// Two ways in, because a composition can be either shape. A top-level one
|
|
// reads overrides off `window.__hfVariables`; one mounted through
|
|
// `data-composition-src` is fed from its host's `data-variable-values`,
|
|
// which the loader reads before the sub-composition runs — so that has to
|
|
// be in the markup, not assigned afterwards.
|
|
" function withValues(source, values) {",
|
|
" var json = JSON.stringify(values);",
|
|
" var attr = json.replace(/'/g, ''');",
|
|
" var out = source.replace(/\\sdata-variable-values=(?:\"[^\"]*\"|'[^']*')/gi, '');",
|
|
" out = out.replace(/(data-composition-src=)/gi, \"data-variable-values='\" + attr + \"' $1\");",
|
|
" var tag = '<' + 'script>window.__hfVariables=' + json + ';<' + '/script>';",
|
|
" return /<head[^>]*>/i.test(out)",
|
|
" ? out.replace(/<head([^>]*)>/i, '<head$1>' + tag)",
|
|
" : tag + out;",
|
|
" }",
|
|
// The composition reads its variables once, at init, so a new value can
|
|
// only arrive by mounting it again. The playhead is carried across so a
|
|
// change mid-shot does not throw the reader back to frame zero.
|
|
" function arm(resumeAt) {",
|
|
" clearInterval(poll);",
|
|
" var last = -1, tries = 0, seeked = false;",
|
|
" poll = setInterval(function () {",
|
|
" if (player.ready) {",
|
|
" if (!seeked) { seeked = true; if (resumeAt > 0) player.seek(resumeAt); }",
|
|
" player.play();",
|
|
" }",
|
|
" if (seeked && player.currentTime > 0 && player.currentTime !== last) {",
|
|
" clearInterval(poll); return;",
|
|
" }",
|
|
" last = player.currentTime;",
|
|
" if (++tries > 150) clearInterval(poll);",
|
|
" }, 100);",
|
|
" }",
|
|
" function mount(values, resumeAt) {",
|
|
" if (html === null) return;",
|
|
" player.setAttribute('srcdoc', withValues(html, values));",
|
|
" arm(resumeAt || 0);",
|
|
" }",
|
|
" player = document.createElement('hyperframes-player');",
|
|
" player.setAttribute('controls', ''); player.setAttribute('muted', '');",
|
|
" document.body.appendChild(player);",
|
|
" player.addEventListener('ended', function () { player.seek(0); player.play(); });",
|
|
" fetch(PAYLOAD).then(function (r) { return r.json(); }).then(function (d) {",
|
|
" html = d.html; mount(INITIAL, 0);",
|
|
" }).catch(function (e) {",
|
|
" document.body.innerHTML = '<pre style=\"color:#f66;font:12px monospace;padding:12px\">preview unavailable: ' + e + '</pre>';",
|
|
" });",
|
|
" addEventListener('message', function (event) {",
|
|
" var values = event.data && event.data.hfVariables;",
|
|
" if (!values) return;",
|
|
" mount(values, player.currentTime || 0);",
|
|
" });",
|
|
"})();",
|
|
"</" + "script></body></html>",
|
|
].join("");
|
|
|
|
useEffect(() => {
|
|
if (typing !== null) return;
|
|
const payload = JSON.stringify(values);
|
|
// Focusing a field and leaving it alone still ends an edit, and remounting
|
|
// on that would restart the composition for nothing.
|
|
if (payload === posted.current) return;
|
|
const timer = setTimeout(() => {
|
|
const target = frame.current && frame.current.contentWindow;
|
|
if (!target) return;
|
|
posted.current = payload;
|
|
target.postMessage({ hfVariables: values }, window.location.origin);
|
|
}, 150);
|
|
return () => clearTimeout(timer);
|
|
}, [values, typing]);
|
|
|
|
// The mount element, as coloured tokens.
|
|
//
|
|
// The values are indented rather than printed on one line: a reader looking
|
|
// at 200 characters of `{"a":1,"b":2,...}` reads it as minified output rather
|
|
// than as something they are meant to edit. Two extra spaces per line keep
|
|
// the object under its own attribute. Newlines inside a quoted HTML attribute
|
|
// are legal and JSON.parse ignores them, so this still pastes and runs.
|
|
const printed = JSON.stringify(values, null, 2).split("\n").join("\n ");
|
|
const attributes = [
|
|
["data-composition-id", `"${compositionId}"`],
|
|
["data-composition-src", `"${compositionSrc}"`],
|
|
["data-variable-values", `'${printed}'`],
|
|
];
|
|
|
|
// One entry per rendered line, each a list of [style, text] tokens — the
|
|
// shape a shiki fence emits. Built rather than written out because an
|
|
// indented value spans several lines and each one needs its own `line` span.
|
|
const snippetLines = [[[SHIKI.punct, "<"], [SHIKI.tag, "div"]]];
|
|
for (const [name, literal] of attributes) {
|
|
const [head, ...rest] = literal.split("\n");
|
|
snippetLines.push([[SHIKI.attr, ` ${name}`], [SHIKI.equals, "="], [SHIKI.value, head]]);
|
|
for (const line of rest) snippetLines.push([[SHIKI.value, line]]);
|
|
}
|
|
snippetLines.push([[SHIKI.punct, "></"], [SHIKI.tag, "div"], [SHIKI.punct, ">"]]);
|
|
|
|
const dirty = variables.some((v) => values[v.id] !== defaults[v.id]);
|
|
|
|
// Only the values that differ, so an untouched piece offers the same short
|
|
// command the Install block does, and a tuned one carries exactly what
|
|
// changed rather than every variable restated.
|
|
const installCommand = (() => {
|
|
const base = `npx hyperframes add ${compositionId}`;
|
|
if (!dirty) return base;
|
|
const changed = {};
|
|
for (const v of variables) {
|
|
if (JSON.stringify(values[v.id]) !== JSON.stringify(defaults[v.id])) changed[v.id] = values[v.id];
|
|
}
|
|
return `${base} --vars '${JSON.stringify(changed)}'`;
|
|
})();
|
|
|
|
// "Code" is the composition's own source, handed in as a fenced block by the
|
|
// generator and highlighted by shiki at build time — the source never changes
|
|
// as a knob moves, so it needs none of the hand-colouring the snippet does.
|
|
// A source with a fence in it cannot be passed this way, and then there is
|
|
// simply no Code tab rather than an empty one.
|
|
const TABS = [
|
|
["preview", "Preview"],
|
|
...(children ? [["code", "Code"]] : []),
|
|
["snippet", "Snippet"],
|
|
];
|
|
|
|
return (
|
|
<div className="hf-ve not-prose my-4">
|
|
<style dangerouslySetInnerHTML={{ __html: CSS }} />
|
|
|
|
<div className="mb-3 flex items-center gap-3">
|
|
<div className="hf-ve-tabs">
|
|
{TABS.map(([id, label]) => (
|
|
<button
|
|
key={id}
|
|
type="button"
|
|
data-on={tab === id}
|
|
aria-pressed={tab === id}
|
|
onClick={() => setTab(id)}
|
|
className="hf-ve-tab hf-ve-tint"
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* One grid cell, two panes. Switching tabs must not unmount the iframe —
|
|
that reloads the composition and throws it back to frame zero. */}
|
|
<div className="hf-ve-frame">
|
|
<div className="hf-ve-cell" data-on={tab === "preview"}>
|
|
<iframe
|
|
ref={frame}
|
|
srcDoc={bootstrap}
|
|
className="hf-ve-preview block aspect-video w-full"
|
|
title={`${compositionId} preview`}
|
|
/>
|
|
</div>
|
|
{children && (
|
|
<div className="hf-ve-cell" data-on={tab === "code"}>
|
|
{children}
|
|
</div>
|
|
)}
|
|
<div className="hf-ve-cell hf-ve-snippet" data-on={tab === "snippet"}>
|
|
{/* The Install block above the preview is generated before anyone
|
|
touches a knob, so it can only ever offer the plain command. This
|
|
one is the panel's, and it carries what the reader actually chose:
|
|
copying it installs the piece already tuned. */}
|
|
<CodeBlock filename="Terminal">
|
|
<pre
|
|
className="shiki shiki-themes github-light-default dark-plus"
|
|
style={{
|
|
backgroundColor: "rgb(255, 255, 255)",
|
|
"--shiki-dark-bg": "#0B0C0E",
|
|
color: "rgb(31, 35, 40)",
|
|
"--shiki-dark": "#D4D4D4",
|
|
}}
|
|
>
|
|
<code>
|
|
<span className="line">
|
|
<span style={SHIKI.value}>{installCommand}</span>
|
|
{"\n"}
|
|
</span>
|
|
</code>
|
|
</pre>
|
|
</CodeBlock>
|
|
{/* Each line carries its own trailing newline rather than sitting
|
|
next to a bare one: MDX resolves a dotted JSX tag through the
|
|
page, so `<React.Fragment>` throws where a keyed element does
|
|
not. The text content is identical either way. */}
|
|
<CodeBlock filename="index.html">
|
|
<pre
|
|
className="shiki shiki-themes github-light-default dark-plus"
|
|
style={{
|
|
backgroundColor: "rgb(255, 255, 255)",
|
|
"--shiki-dark-bg": "#0B0C0E",
|
|
color: "rgb(31, 35, 40)",
|
|
"--shiki-dark": "#D4D4D4",
|
|
}}
|
|
>
|
|
<code>
|
|
{snippetLines.map((tokens, line) => (
|
|
<span key={line} className="line">
|
|
{tokens.map(([style, text], token) => (
|
|
<span key={token} style={style}>
|
|
{text}
|
|
</span>
|
|
))}
|
|
{"\n"}
|
|
</span>
|
|
))}
|
|
</code>
|
|
</pre>
|
|
</CodeBlock>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="hf-ve-panel">
|
|
<div className="hf-ve-head">
|
|
<span className="hf-ve-title">Customize</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setValues(defaults);
|
|
setNotes({});
|
|
}}
|
|
disabled={!dirty}
|
|
className="hf-ve-btn hf-ve-tint"
|
|
>
|
|
Reset
|
|
</button>
|
|
</div>
|
|
<div className="hf-ve-grid">
|
|
{variables.map((v) => (
|
|
<div key={v.id}>
|
|
<div className="hf-ve-row">
|
|
<label className="hf-ve-label">{v.label ?? v.id}</label>
|
|
<span className="hf-ve-value">{readout(v, values[v.id])}</span>
|
|
</div>
|
|
{control(
|
|
v,
|
|
values[v.id],
|
|
(next) => setValues((prev) => ({ ...prev, [v.id]: next })),
|
|
notes[v.id],
|
|
(note) => setNotes((prev) => ({ ...prev, [v.id]: note })),
|
|
setTyping,
|
|
)}
|
|
{v.description && <p className="hf-ve-desc">{v.description}</p>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|