@texuli/core 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -5
- package/dist/editor/document.js +8 -1
- package/dist/editor/extensions.d.ts +64 -0
- package/dist/editor/extensions.js +116 -0
- package/dist/editor/inline.d.ts +5 -3
- package/dist/editor/inline.js +24 -12
- package/dist/editor/load.d.ts +10 -0
- package/dist/editor/load.js +16 -0
- package/dist/editor/markdown.d.ts +10 -1
- package/dist/editor/markdown.js +193 -83
- package/dist/editor/model.d.ts +3 -1
- package/dist/editor/model.js +3 -1
- package/dist/editor/transforms.d.ts +1 -1
- package/dist/editor/tree.d.ts +11 -1
- package/dist/editor/tree.js +11 -2
- package/dist/editor/types.d.ts +10 -0
- package/dist/editor/types.js +1 -0
- package/dist/inert/inert.d.ts +11 -0
- package/dist/inert/inert.js +5 -3
- package/dist/markup/index.d.ts +5 -0
- package/dist/markup/index.js +5 -0
- package/dist/markup/markup.d.ts +72 -0
- package/dist/markup/markup.js +233 -0
- package/dist/motion/animate.d.ts +109 -0
- package/dist/motion/animate.js +225 -0
- package/dist/motion/flip.d.ts +58 -0
- package/dist/motion/flip.js +167 -0
- package/dist/motion/index.d.ts +15 -0
- package/dist/motion/index.js +15 -0
- package/dist/motion/preferences.d.ts +68 -0
- package/dist/motion/preferences.js +111 -0
- package/dist/motion/presence.d.ts +61 -0
- package/dist/motion/presence.js +231 -0
- package/dist/motion/sequence.d.ts +70 -0
- package/dist/motion/sequence.js +159 -0
- package/dist/motion/shared.d.ts +45 -0
- package/dist/motion/shared.js +144 -0
- package/dist/motion/spring.d.ts +109 -0
- package/dist/motion/spring.js +166 -0
- package/dist/overlay/core.js +14 -3
- package/dist/zip/index.d.ts +5 -0
- package/dist/zip/index.js +5 -0
- package/dist/zip/zip.d.ts +35 -0
- package/dist/zip/zip.js +190 -0
- package/package.json +6 -2
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escaped markup builders (ADR-016): typed element and text nodes that
|
|
3
|
+
* serialize to XML or HTML. Content only enters through these nodes, so
|
|
4
|
+
* there is no raw-markup API: every text and attribute value is escaped,
|
|
5
|
+
* element and attribute names are checked, and URL-valued HTML attributes
|
|
6
|
+
* accept only a `MarkupUrl` made by `linkUrl` or `imageUrl`, which call the
|
|
7
|
+
* URL policy with an explicit tier. Serialization is iterative and linear in
|
|
8
|
+
* the size of the tree.
|
|
9
|
+
*/
|
|
10
|
+
import { type HrefTrust } from '../url/safe-href.js';
|
|
11
|
+
export interface MarkupText {
|
|
12
|
+
readonly kind: 'text';
|
|
13
|
+
readonly value: string;
|
|
14
|
+
}
|
|
15
|
+
/** A URL that passed the URL policy. Only `linkUrl` and `imageUrl` make one the serializers accept. */
|
|
16
|
+
export interface MarkupUrl {
|
|
17
|
+
readonly kind: 'url';
|
|
18
|
+
readonly value: string;
|
|
19
|
+
}
|
|
20
|
+
export type MarkupAttributeValue = string | number | boolean | MarkupUrl | null | undefined;
|
|
21
|
+
export type MarkupAttribute = readonly [string, string | boolean | MarkupUrl];
|
|
22
|
+
export interface MarkupElement {
|
|
23
|
+
readonly kind: 'element';
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly attributes: readonly MarkupAttribute[];
|
|
26
|
+
readonly children: readonly MarkupNode[];
|
|
27
|
+
}
|
|
28
|
+
export type MarkupNode = MarkupElement | MarkupText;
|
|
29
|
+
/** A child: a node, a string or number (escaped text), a nested list, or a skipped `null`, `undefined` or `false`. */
|
|
30
|
+
export type MarkupChild = MarkupNode | string | number | null | undefined | false | readonly MarkupChild[];
|
|
31
|
+
export interface XmlSerializeOptions {
|
|
32
|
+
/** Prefix the UTF-8 declaration (`standalone="yes"`, as Office packages use). Default true. */
|
|
33
|
+
declaration?: boolean;
|
|
34
|
+
/** An allowlist for element and attribute names, on top of the XML name grammar. */
|
|
35
|
+
allow?: (name: string, what: 'element' | 'attribute') => boolean;
|
|
36
|
+
}
|
|
37
|
+
export interface HtmlSerializeOptions {
|
|
38
|
+
/** Prefix `<!doctype html>`. Default false. */
|
|
39
|
+
doctype?: boolean;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The HTML elements `serializeHtml` writes: document structure, text,
|
|
43
|
+
* lists, tables, links and images. No scripts, styles, frames, objects,
|
|
44
|
+
* forms, templates, SVG or MathML.
|
|
45
|
+
*/
|
|
46
|
+
export declare const HTML_ELEMENTS: ReadonlySet<string>;
|
|
47
|
+
/** HTML attributes that hold a URL: only a `MarkupUrl` value is written. */
|
|
48
|
+
export declare const HTML_URL_ATTRIBUTES: ReadonlySet<string>;
|
|
49
|
+
/** A link URL through `safeHref` at `trust`, or `null` when the policy refuses it. */
|
|
50
|
+
export declare function linkUrl(value: string, trust: HrefTrust): MarkupUrl | null;
|
|
51
|
+
/** An image URL through `safeImageSrc` at `trust`, or `null` when the policy refuses it. */
|
|
52
|
+
export declare function imageUrl(value: string, trust: HrefTrust): MarkupUrl | null;
|
|
53
|
+
/** True only for a URL `linkUrl` or `imageUrl` made (a hand-built object is not one). */
|
|
54
|
+
export declare function isMarkupUrl(value: unknown): value is MarkupUrl;
|
|
55
|
+
/**
|
|
56
|
+
* Drop characters XML 1.0 cannot carry: C0 controls other than tab,
|
|
57
|
+
* newline and carriage return, U+FFFE and U+FFFF, and unpaired surrogates.
|
|
58
|
+
*/
|
|
59
|
+
export declare function sanitizeMarkupChars(value: string): string;
|
|
60
|
+
export declare function escapeMarkupText(value: string): string;
|
|
61
|
+
export declare function escapeMarkupAttribute(value: string): string;
|
|
62
|
+
export declare function text(value: string | number): MarkupText;
|
|
63
|
+
/** Build an element. String and number children become escaped text; nullish and `false` children are skipped. */
|
|
64
|
+
export declare function el(name: string, attributes?: Readonly<Record<string, MarkupAttributeValue>>, ...children: MarkupChild[]): MarkupElement;
|
|
65
|
+
/** Serialize to XML: booleans become `1` and `0` (the Office convention); childless elements self-close. */
|
|
66
|
+
export declare function serializeXml(root: MarkupElement, options?: XmlSerializeOptions): string;
|
|
67
|
+
/**
|
|
68
|
+
* Serialize to HTML: only `HTML_ELEMENTS`, no event handler or `srcdoc`
|
|
69
|
+
* attributes, URL attributes only from `linkUrl` or `imageUrl`, booleans as
|
|
70
|
+
* bare attributes, and void elements without an end tag.
|
|
71
|
+
*/
|
|
72
|
+
export declare function serializeHtml(root: MarkupElement, options?: HtmlSerializeOptions): string;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escaped markup builders (ADR-016): typed element and text nodes that
|
|
3
|
+
* serialize to XML or HTML. Content only enters through these nodes, so
|
|
4
|
+
* there is no raw-markup API: every text and attribute value is escaped,
|
|
5
|
+
* element and attribute names are checked, and URL-valued HTML attributes
|
|
6
|
+
* accept only a `MarkupUrl` made by `linkUrl` or `imageUrl`, which call the
|
|
7
|
+
* URL policy with an explicit tier. Serialization is iterative and linear in
|
|
8
|
+
* the size of the tree.
|
|
9
|
+
*/
|
|
10
|
+
import { safeHref } from "../url/safe-href.js";
|
|
11
|
+
import { safeImageSrc } from "../url-policy/safe-image-src.js";
|
|
12
|
+
const XML_NAME = /^[A-Za-z_][A-Za-z0-9._-]*(?::[A-Za-z_][A-Za-z0-9._-]*)?$/;
|
|
13
|
+
const HTML_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
14
|
+
const HTML_ATTRIBUTE = /^[a-z][a-z0-9]*(?:[-:][a-z0-9]+)*$/;
|
|
15
|
+
/**
|
|
16
|
+
* The HTML elements `serializeHtml` writes: document structure, text,
|
|
17
|
+
* lists, tables, links and images. No scripts, styles, frames, objects,
|
|
18
|
+
* forms, templates, SVG or MathML.
|
|
19
|
+
*/
|
|
20
|
+
export const HTML_ELEMENTS = new Set(('html head body title meta main article section header footer nav aside div span p br hr ' +
|
|
21
|
+
'h1 h2 h3 h4 h5 h6 strong b em i u s del ins code pre kbd samp small sub sup mark blockquote ' +
|
|
22
|
+
'q cite abbr time ul ol li dl dt dd a img figure figcaption table caption thead tbody tfoot ' +
|
|
23
|
+
'tr th td colgroup col center font address details summary wbr').split(' '));
|
|
24
|
+
/** HTML elements written without children or an end tag. */
|
|
25
|
+
const HTML_VOID = new Set(['br', 'hr', 'img', 'meta', 'col', 'wbr']);
|
|
26
|
+
/** HTML attributes that hold a URL: only a `MarkupUrl` value is written. */
|
|
27
|
+
export const HTML_URL_ATTRIBUTES = new Set(('href src srcset action formaction poster cite data background xlink:href ping manifest ' +
|
|
28
|
+
'codebase longdesc usemap lowsrc dynsrc').split(' '));
|
|
29
|
+
/** HTML attributes no output carries: event handlers are refused by prefix, these by name. */
|
|
30
|
+
const HTML_DENIED_ATTRIBUTES = new Set(['srcdoc', 'http-equiv', 'is', 'nonce']);
|
|
31
|
+
const urls = new WeakSet();
|
|
32
|
+
function makeUrl(value) {
|
|
33
|
+
if (value === undefined)
|
|
34
|
+
return null;
|
|
35
|
+
const url = Object.freeze({ kind: 'url', value });
|
|
36
|
+
urls.add(url);
|
|
37
|
+
return url;
|
|
38
|
+
}
|
|
39
|
+
/** A link URL through `safeHref` at `trust`, or `null` when the policy refuses it. */
|
|
40
|
+
export function linkUrl(value, trust) {
|
|
41
|
+
return makeUrl(safeHref(value, trust));
|
|
42
|
+
}
|
|
43
|
+
/** An image URL through `safeImageSrc` at `trust`, or `null` when the policy refuses it. */
|
|
44
|
+
export function imageUrl(value, trust) {
|
|
45
|
+
return makeUrl(safeImageSrc(value, trust));
|
|
46
|
+
}
|
|
47
|
+
/** True only for a URL `linkUrl` or `imageUrl` made (a hand-built object is not one). */
|
|
48
|
+
export function isMarkupUrl(value) {
|
|
49
|
+
return typeof value === 'object' && value !== null && urls.has(value);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Drop characters XML 1.0 cannot carry: C0 controls other than tab,
|
|
53
|
+
* newline and carriage return, U+FFFE and U+FFFF, and unpaired surrogates.
|
|
54
|
+
*/
|
|
55
|
+
export function sanitizeMarkupChars(value) {
|
|
56
|
+
// Fast path: most text has nothing to drop.
|
|
57
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching the controls is the point.
|
|
58
|
+
if (!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ud800-\udfff]/.test(value))
|
|
59
|
+
return value;
|
|
60
|
+
let out = '';
|
|
61
|
+
for (const character of value) {
|
|
62
|
+
const code = character.codePointAt(0) ?? 0;
|
|
63
|
+
if (code === 0x09 || code === 0x0a || code === 0x0d)
|
|
64
|
+
out += character;
|
|
65
|
+
else if (code < 0x20 || code === 0xfffe || code === 0xffff)
|
|
66
|
+
continue;
|
|
67
|
+
else if (code >= 0xd800 && code <= 0xdfff)
|
|
68
|
+
continue;
|
|
69
|
+
else
|
|
70
|
+
out += character;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
export function escapeMarkupText(value) {
|
|
75
|
+
return sanitizeMarkupChars(value)
|
|
76
|
+
.replace(/&/g, '&')
|
|
77
|
+
.replace(/</g, '<')
|
|
78
|
+
.replace(/>/g, '>');
|
|
79
|
+
}
|
|
80
|
+
export function escapeMarkupAttribute(value) {
|
|
81
|
+
return escapeMarkupText(value)
|
|
82
|
+
.replace(/"/g, '"')
|
|
83
|
+
.replace(/'/g, ''')
|
|
84
|
+
.replace(/\t/g, '	')
|
|
85
|
+
.replace(/\n/g, ' ')
|
|
86
|
+
.replace(/\r/g, ' ');
|
|
87
|
+
}
|
|
88
|
+
export function text(value) {
|
|
89
|
+
return { kind: 'text', value: String(value) };
|
|
90
|
+
}
|
|
91
|
+
function pushChildren(out, children) {
|
|
92
|
+
// Iterative flattening, so a deeply nested child list cannot overflow the stack.
|
|
93
|
+
const stack = [children, 0];
|
|
94
|
+
while (stack.length > 0) {
|
|
95
|
+
const index = stack.pop();
|
|
96
|
+
const list = stack.pop();
|
|
97
|
+
if (index >= list.length)
|
|
98
|
+
continue;
|
|
99
|
+
stack.push(list, index + 1);
|
|
100
|
+
const child = list[index];
|
|
101
|
+
if (child === null || child === undefined || child === false)
|
|
102
|
+
continue;
|
|
103
|
+
if (Array.isArray(child))
|
|
104
|
+
stack.push(child, 0);
|
|
105
|
+
else if (typeof child === 'string' || typeof child === 'number')
|
|
106
|
+
out.push(text(child));
|
|
107
|
+
else
|
|
108
|
+
out.push(child);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function assertXmlName(name, what) {
|
|
112
|
+
if (typeof name !== 'string' || !XML_NAME.test(name)) {
|
|
113
|
+
throw new TypeError(`Invalid markup ${what} name: ${JSON.stringify(name)}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Build an element. String and number children become escaped text; nullish and `false` children are skipped. */
|
|
117
|
+
export function el(name, attributes = {}, ...children) {
|
|
118
|
+
assertXmlName(name, 'element');
|
|
119
|
+
const attrs = [];
|
|
120
|
+
for (const key of Object.keys(attributes)) {
|
|
121
|
+
const value = attributes[key];
|
|
122
|
+
if (value === undefined || value === null)
|
|
123
|
+
continue;
|
|
124
|
+
assertXmlName(key, 'attribute');
|
|
125
|
+
if (typeof value === 'number') {
|
|
126
|
+
if (!Number.isFinite(value))
|
|
127
|
+
throw new TypeError(`Attribute ${key} must be a finite number.`);
|
|
128
|
+
attrs.push([key, String(value)]);
|
|
129
|
+
}
|
|
130
|
+
else if (typeof value === 'object') {
|
|
131
|
+
if (!isMarkupUrl(value))
|
|
132
|
+
throw new TypeError(`Attribute ${key} holds an unchecked URL object.`);
|
|
133
|
+
attrs.push([key, value]);
|
|
134
|
+
}
|
|
135
|
+
else
|
|
136
|
+
attrs.push([key, value]);
|
|
137
|
+
}
|
|
138
|
+
const nodes = [];
|
|
139
|
+
pushChildren(nodes, children);
|
|
140
|
+
return { kind: 'element', name, attributes: attrs, children: nodes };
|
|
141
|
+
}
|
|
142
|
+
function walk(root, open, close, leaf) {
|
|
143
|
+
if (root.kind === 'text')
|
|
144
|
+
return leaf(root);
|
|
145
|
+
const parts = [open(root)];
|
|
146
|
+
const stack = [{ node: root, index: 0 }];
|
|
147
|
+
while (stack.length > 0) {
|
|
148
|
+
const frame = stack[stack.length - 1];
|
|
149
|
+
const child = frame.node.children[frame.index];
|
|
150
|
+
if (child === undefined) {
|
|
151
|
+
parts.push(close(frame.node));
|
|
152
|
+
stack.pop();
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
frame.index += 1;
|
|
156
|
+
if (child.kind === 'text')
|
|
157
|
+
parts.push(leaf(child));
|
|
158
|
+
else if (child.kind === 'element') {
|
|
159
|
+
parts.push(open(child));
|
|
160
|
+
stack.push({ node: child, index: 0 });
|
|
161
|
+
}
|
|
162
|
+
else
|
|
163
|
+
throw new TypeError('Unknown markup node.');
|
|
164
|
+
}
|
|
165
|
+
return parts.join('');
|
|
166
|
+
}
|
|
167
|
+
/** Serialize to XML: booleans become `1` and `0` (the Office convention); childless elements self-close. */
|
|
168
|
+
export function serializeXml(root, options = {}) {
|
|
169
|
+
const allow = options.allow;
|
|
170
|
+
const check = (name, what) => {
|
|
171
|
+
assertXmlName(name, what);
|
|
172
|
+
if (allow && !allow(name, what)) {
|
|
173
|
+
throw new TypeError(`Markup ${what} not allowed: ${JSON.stringify(name)}`);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const body = walk(root, (node) => {
|
|
177
|
+
// Re-check names, so a hand-built node object cannot smuggle markup.
|
|
178
|
+
check(node.name, 'element');
|
|
179
|
+
let out = `<${node.name}`;
|
|
180
|
+
for (const [key, value] of node.attributes) {
|
|
181
|
+
check(key, 'attribute');
|
|
182
|
+
const raw = typeof value === 'boolean' ? (value ? '1' : '0') : xmlValue(value);
|
|
183
|
+
out += ` ${key}="${escapeMarkupAttribute(raw)}"`;
|
|
184
|
+
}
|
|
185
|
+
return node.children.length === 0 ? `${out}/>` : `${out}>`;
|
|
186
|
+
}, (node) => (node.children.length === 0 ? '' : `</${node.name}>`), (node) => escapeMarkupText(String(node.value)));
|
|
187
|
+
return options.declaration === false
|
|
188
|
+
? body
|
|
189
|
+
: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${body}`;
|
|
190
|
+
}
|
|
191
|
+
function xmlValue(value) {
|
|
192
|
+
if (typeof value === 'string')
|
|
193
|
+
return value;
|
|
194
|
+
if (!isMarkupUrl(value))
|
|
195
|
+
throw new TypeError('Unchecked URL object in markup.');
|
|
196
|
+
return value.value;
|
|
197
|
+
}
|
|
198
|
+
function htmlAttribute(name, value) {
|
|
199
|
+
if (!HTML_ATTRIBUTE.test(name) || name.startsWith('on') || HTML_DENIED_ATTRIBUTES.has(name)) {
|
|
200
|
+
throw new TypeError(`HTML attribute not allowed: ${JSON.stringify(name)}`);
|
|
201
|
+
}
|
|
202
|
+
if (HTML_URL_ATTRIBUTES.has(name)) {
|
|
203
|
+
if (!isMarkupUrl(value)) {
|
|
204
|
+
throw new TypeError(`HTML attribute ${name} takes a URL from linkUrl or imageUrl.`);
|
|
205
|
+
}
|
|
206
|
+
return ` ${name}="${escapeMarkupAttribute(value.value)}"`;
|
|
207
|
+
}
|
|
208
|
+
if (typeof value === 'boolean')
|
|
209
|
+
return value ? ` ${name}` : '';
|
|
210
|
+
if (typeof value !== 'string')
|
|
211
|
+
throw new TypeError(`HTML attribute ${name} is not text.`);
|
|
212
|
+
return ` ${name}="${escapeMarkupAttribute(value)}"`;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Serialize to HTML: only `HTML_ELEMENTS`, no event handler or `srcdoc`
|
|
216
|
+
* attributes, URL attributes only from `linkUrl` or `imageUrl`, booleans as
|
|
217
|
+
* bare attributes, and void elements without an end tag.
|
|
218
|
+
*/
|
|
219
|
+
export function serializeHtml(root, options = {}) {
|
|
220
|
+
const body = walk(root, (node) => {
|
|
221
|
+
if (!HTML_NAME.test(node.name) || !HTML_ELEMENTS.has(node.name)) {
|
|
222
|
+
throw new TypeError(`HTML element not allowed: ${JSON.stringify(node.name)}`);
|
|
223
|
+
}
|
|
224
|
+
if (HTML_VOID.has(node.name) && node.children.length > 0) {
|
|
225
|
+
throw new TypeError(`HTML element ${node.name} takes no children.`);
|
|
226
|
+
}
|
|
227
|
+
let out = `<${node.name}`;
|
|
228
|
+
for (const [key, value] of node.attributes)
|
|
229
|
+
out += htmlAttribute(key, value);
|
|
230
|
+
return `${out}>`;
|
|
231
|
+
}, (node) => (HTML_VOID.has(node.name) ? '' : `</${node.name}>`), (node) => escapeMarkupText(String(node.value)));
|
|
232
|
+
return options.doctype ? `<!doctype html>${body}` : body;
|
|
233
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animate: the Web Animations wrapper every motion texel plays through
|
|
3
|
+
* (ADR-013). It clamps timing, applies the motion level and reduced motion,
|
|
4
|
+
* and commits the end state at once (no frames, `finished` resolves
|
|
5
|
+
* `"instant"`) whenever motion cannot or must not run: on the server, without
|
|
6
|
+
* the Web Animations API, on a detached element, at level `none`, under
|
|
7
|
+
* reduced motion, or with a zero duration.
|
|
8
|
+
*
|
|
9
|
+
* Animations use `fill: "none"` (`"backwards"` only while a delay holds the
|
|
10
|
+
* first frame), so no inline style outlives them; the final state belongs in
|
|
11
|
+
* classes, attributes or props.
|
|
12
|
+
*/
|
|
13
|
+
import { type MotionLevel, type ReducedMotionPolicy, type ReducedMotionSource } from './preferences.js';
|
|
14
|
+
import { type SpringParams } from './spring.js';
|
|
15
|
+
/** Keyframes in either Web Animations form. */
|
|
16
|
+
export type MotionKeyframes = Keyframe[] | PropertyIndexedKeyframes;
|
|
17
|
+
/** How a motion run ended. */
|
|
18
|
+
export type MotionOutcome =
|
|
19
|
+
/** Every animation played to its end. */
|
|
20
|
+
'finished'
|
|
21
|
+
/** The end state was committed without frames (see {@link InstantReason}). */
|
|
22
|
+
| 'instant'
|
|
23
|
+
/** `cancel()` stopped it. */
|
|
24
|
+
| 'cancelled'
|
|
25
|
+
/** A newer run took over (a reversal, a new measure, a new transition). */
|
|
26
|
+
| 'interrupted';
|
|
27
|
+
/** Why a run committed instantly. */
|
|
28
|
+
export type InstantReason = 'server' | 'detached' | 'level-none' | 'reduced-motion' | 'no-properties' | 'zero-duration' | 'no-waapi' | 'error' | 'limit';
|
|
29
|
+
/** Reduced motion: a fixed value, a live source, or omitted (read the element's window). */
|
|
30
|
+
export type ReducedMotionInput = boolean | ReducedMotionSource | null | undefined;
|
|
31
|
+
/** Timing and policy shared by every motion texel. */
|
|
32
|
+
export type MotionTiming = {
|
|
33
|
+
/** Milliseconds, clamped to [0, 1000]; NaN or negative is 0 (instant). Default 200. */
|
|
34
|
+
duration?: number;
|
|
35
|
+
/** Milliseconds before the animation starts, clamped like `duration`. Default 0. */
|
|
36
|
+
delay?: number;
|
|
37
|
+
/** A CSS easing. Default `cubic-bezier(0.4, 0, 0.2, 1)` (the `standard` token). */
|
|
38
|
+
easing?: string;
|
|
39
|
+
/** Play on a spring instead: the duration is its settle time and the easing its `linear()` curve. */
|
|
40
|
+
spring?: Partial<SpringParams> & {
|
|
41
|
+
velocity?: number;
|
|
42
|
+
};
|
|
43
|
+
/** The already-resolved motion level (see `resolveMotionLevel`). Default `full`. */
|
|
44
|
+
level?: MotionLevel;
|
|
45
|
+
/** Reduced motion; omitted reads `prefers-reduced-motion` from the element's window. */
|
|
46
|
+
reducedMotion?: ReducedMotionInput;
|
|
47
|
+
/** `instant` (default) commits the end state; `fade` keeps an opacity-only 150 ms fade. */
|
|
48
|
+
reducedMotionPolicy?: ReducedMotionPolicy;
|
|
49
|
+
};
|
|
50
|
+
/** One running (or already settled) motion. */
|
|
51
|
+
export type MotionRun = {
|
|
52
|
+
/** Resolves once with the outcome; never rejects. */
|
|
53
|
+
readonly finished: Promise<MotionOutcome>;
|
|
54
|
+
/** The live animations (empty when instant). */
|
|
55
|
+
readonly animations: readonly Animation[];
|
|
56
|
+
/** Why it committed instantly, or `null` when it animates. */
|
|
57
|
+
readonly instantReason: InstantReason | null;
|
|
58
|
+
/** Stop now; no style remains. Resolves `finished` with `"cancelled"`. */
|
|
59
|
+
cancel: () => void;
|
|
60
|
+
/** Jump to the end. Resolves `finished` with `"finished"`. */
|
|
61
|
+
finish: () => void;
|
|
62
|
+
};
|
|
63
|
+
export type AnimateOptions = MotionTiming & {
|
|
64
|
+
/** Called once with the outcome, before `finished` resolves. */
|
|
65
|
+
onFinish?: (outcome: MotionOutcome) => void;
|
|
66
|
+
};
|
|
67
|
+
/** Default duration (the `base` token) and easing (the `standard` token). */
|
|
68
|
+
export declare const DEFAULT_MOTION_DURATION = 200;
|
|
69
|
+
export declare const DEFAULT_MOTION_EASING = "cubic-bezier(0.4, 0, 0.2, 1)";
|
|
70
|
+
/** Resolved, clamped plan for one animation. */
|
|
71
|
+
export type ResolvedMotion = {
|
|
72
|
+
readonly instant: true;
|
|
73
|
+
readonly reason: InstantReason;
|
|
74
|
+
} | {
|
|
75
|
+
readonly instant: false;
|
|
76
|
+
readonly keyframes: MotionKeyframes;
|
|
77
|
+
readonly duration: number;
|
|
78
|
+
readonly delay: number;
|
|
79
|
+
readonly easing: string;
|
|
80
|
+
};
|
|
81
|
+
/** Keyframes without movement or scale (what `subtle` plays). `null` when nothing is left. */
|
|
82
|
+
export declare function withoutTravel(keyframes: MotionKeyframes): MotionKeyframes | null;
|
|
83
|
+
/**
|
|
84
|
+
* Resolve keyframes and timing for one element: the level, reduced motion,
|
|
85
|
+
* clamping and the environment checks, without starting anything.
|
|
86
|
+
*/
|
|
87
|
+
export declare function resolveMotion(element: Element | null | undefined, keyframes: MotionKeyframes, timing?: MotionTiming): ResolvedMotion;
|
|
88
|
+
/** Start a resolved plan; `null` when the browser refuses it (an invalid easing, say). */
|
|
89
|
+
export declare function startAnimation(element: Element, plan: Extract<ResolvedMotion, {
|
|
90
|
+
instant: false;
|
|
91
|
+
}>, extra?: {
|
|
92
|
+
endDelay?: number;
|
|
93
|
+
}): Animation | null;
|
|
94
|
+
/**
|
|
95
|
+
* Settle a set of animations as one run. With no animations the run is
|
|
96
|
+
* instant: `onFinish` fires synchronously and `finished` resolves `"instant"`.
|
|
97
|
+
*/
|
|
98
|
+
export declare function createRun(animations: readonly Animation[], onFinish?: (outcome: MotionOutcome) => void, instantReason?: InstantReason | null): MotionRun & {
|
|
99
|
+
interrupt: () => void;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Animate one element through the Web Animations API with the motion level
|
|
103
|
+
* and reduced motion applied.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* const run = animate(card, [{ opacity: 0 }, { opacity: 1 }], { duration: 150 });
|
|
107
|
+
* await run.finished; // "finished", or "instant" under reduced motion
|
|
108
|
+
*/
|
|
109
|
+
export declare function animate(element: Element | null | undefined, keyframes: MotionKeyframes, options?: AnimateOptions): MotionRun;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animate: the Web Animations wrapper every motion texel plays through
|
|
3
|
+
* (ADR-013). It clamps timing, applies the motion level and reduced motion,
|
|
4
|
+
* and commits the end state at once (no frames, `finished` resolves
|
|
5
|
+
* `"instant"`) whenever motion cannot or must not run: on the server, without
|
|
6
|
+
* the Web Animations API, on a detached element, at level `none`, under
|
|
7
|
+
* reduced motion, or with a zero duration.
|
|
8
|
+
*
|
|
9
|
+
* Animations use `fill: "none"` (`"backwards"` only while a delay holds the
|
|
10
|
+
* first frame), so no inline style outlives them; the final state belongs in
|
|
11
|
+
* classes, attributes or props.
|
|
12
|
+
*/
|
|
13
|
+
import { getOwnerWindow } from "../env/index.js";
|
|
14
|
+
import { prefersReducedMotion } from "../presence/presence.js";
|
|
15
|
+
import { motionDuration, REDUCED_FADE_MS, } from "./preferences.js";
|
|
16
|
+
import { springEasing } from "./spring.js";
|
|
17
|
+
/** Default duration (the `base` token) and easing (the `standard` token). */
|
|
18
|
+
export const DEFAULT_MOTION_DURATION = 200;
|
|
19
|
+
export const DEFAULT_MOTION_EASING = 'cubic-bezier(0.4, 0, 0.2, 1)';
|
|
20
|
+
const SPRING_FALLBACK_EASING = 'cubic-bezier(0.2, 0, 0, 1)';
|
|
21
|
+
/** Properties that move or scale: dropped at level `subtle`. */
|
|
22
|
+
const TRAVEL = new Set([
|
|
23
|
+
'transform',
|
|
24
|
+
'transformOrigin',
|
|
25
|
+
'translate',
|
|
26
|
+
'scale',
|
|
27
|
+
'rotate',
|
|
28
|
+
'offsetDistance',
|
|
29
|
+
]);
|
|
30
|
+
const META = new Set(['offset', 'easing', 'composite']);
|
|
31
|
+
function filterKeyframes(keyframes, keep) {
|
|
32
|
+
let any = false;
|
|
33
|
+
if (Array.isArray(keyframes)) {
|
|
34
|
+
const out = keyframes.map((frame) => {
|
|
35
|
+
const next = {};
|
|
36
|
+
for (const [key, value] of Object.entries(frame ?? {})) {
|
|
37
|
+
if (META.has(key) || keep(key)) {
|
|
38
|
+
next[key] = value;
|
|
39
|
+
if (!META.has(key))
|
|
40
|
+
any = true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return next;
|
|
44
|
+
});
|
|
45
|
+
return any ? out : null;
|
|
46
|
+
}
|
|
47
|
+
if (!keyframes || typeof keyframes !== 'object')
|
|
48
|
+
return null;
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const [key, value] of Object.entries(keyframes)) {
|
|
51
|
+
if (META.has(key) || keep(key)) {
|
|
52
|
+
out[key] = value;
|
|
53
|
+
if (!META.has(key))
|
|
54
|
+
any = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return any ? out : null;
|
|
58
|
+
}
|
|
59
|
+
/** Keyframes without movement or scale (what `subtle` plays). `null` when nothing is left. */
|
|
60
|
+
export function withoutTravel(keyframes) {
|
|
61
|
+
return filterKeyframes(keyframes, (property) => !TRAVEL.has(property));
|
|
62
|
+
}
|
|
63
|
+
function opacityOnly(keyframes) {
|
|
64
|
+
return filterKeyframes(keyframes, (property) => property === 'opacity');
|
|
65
|
+
}
|
|
66
|
+
function readReduced(input, win) {
|
|
67
|
+
if (typeof input === 'boolean')
|
|
68
|
+
return input;
|
|
69
|
+
if (input && typeof input.get === 'function')
|
|
70
|
+
return input.get() === true;
|
|
71
|
+
return prefersReducedMotion(win ?? null, false);
|
|
72
|
+
}
|
|
73
|
+
function supportsLinear(win) {
|
|
74
|
+
try {
|
|
75
|
+
const css = win?.CSS;
|
|
76
|
+
return css?.supports?.('animation-timing-function', 'linear(0, 1)') === true;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolve keyframes and timing for one element: the level, reduced motion,
|
|
84
|
+
* clamping and the environment checks, without starting anything.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveMotion(element, keyframes, timing = {}) {
|
|
87
|
+
const win = element ? getOwnerWindow(element) : undefined;
|
|
88
|
+
if (!element || !win)
|
|
89
|
+
return { instant: true, reason: 'server' };
|
|
90
|
+
if (!element.isConnected)
|
|
91
|
+
return { instant: true, reason: 'detached' };
|
|
92
|
+
const level = timing.level ?? 'full';
|
|
93
|
+
if (level === 'none')
|
|
94
|
+
return { instant: true, reason: 'level-none' };
|
|
95
|
+
let frames = keyframes;
|
|
96
|
+
let duration;
|
|
97
|
+
let delay;
|
|
98
|
+
let easing = typeof timing.easing === 'string' && timing.easing ? timing.easing : DEFAULT_MOTION_EASING;
|
|
99
|
+
if (readReduced(timing.reducedMotion, win)) {
|
|
100
|
+
if (timing.reducedMotionPolicy !== 'fade')
|
|
101
|
+
return { instant: true, reason: 'reduced-motion' };
|
|
102
|
+
frames = opacityOnly(keyframes);
|
|
103
|
+
duration = REDUCED_FADE_MS;
|
|
104
|
+
delay = 0;
|
|
105
|
+
easing = 'linear';
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
if (level === 'subtle')
|
|
109
|
+
frames = withoutTravel(keyframes);
|
|
110
|
+
if (timing.spring) {
|
|
111
|
+
const spring = springEasing(timing.spring, { velocity: timing.spring.velocity ?? 0 });
|
|
112
|
+
duration = motionDuration(spring.durationMs, level);
|
|
113
|
+
easing = supportsLinear(win) ? spring.easing : SPRING_FALLBACK_EASING;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
duration = motionDuration(timing.duration ?? DEFAULT_MOTION_DURATION, level);
|
|
117
|
+
}
|
|
118
|
+
delay = motionDuration(timing.delay ?? 0, level);
|
|
119
|
+
}
|
|
120
|
+
if (!frames)
|
|
121
|
+
return { instant: true, reason: 'no-properties' };
|
|
122
|
+
if (duration <= 0)
|
|
123
|
+
return { instant: true, reason: 'zero-duration' };
|
|
124
|
+
if (typeof element.animate !== 'function') {
|
|
125
|
+
return { instant: true, reason: 'no-waapi' };
|
|
126
|
+
}
|
|
127
|
+
return { instant: false, keyframes: frames, duration, delay, easing };
|
|
128
|
+
}
|
|
129
|
+
/** Start a resolved plan; `null` when the browser refuses it (an invalid easing, say). */
|
|
130
|
+
export function startAnimation(element, plan, extra = {}) {
|
|
131
|
+
try {
|
|
132
|
+
const animation = element.animate(plan.keyframes, {
|
|
133
|
+
duration: plan.duration,
|
|
134
|
+
delay: plan.delay,
|
|
135
|
+
endDelay: extra.endDelay ?? 0,
|
|
136
|
+
easing: plan.easing,
|
|
137
|
+
fill: plan.delay > 0 ? 'backwards' : 'none',
|
|
138
|
+
});
|
|
139
|
+
// A cancelled animation rejects `finished`; never leave that unhandled.
|
|
140
|
+
animation?.finished?.catch(() => { });
|
|
141
|
+
return animation ?? null;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Settle a set of animations as one run. With no animations the run is
|
|
149
|
+
* instant: `onFinish` fires synchronously and `finished` resolves `"instant"`.
|
|
150
|
+
*/
|
|
151
|
+
export function createRun(animations, onFinish, instantReason = null) {
|
|
152
|
+
let settled = false;
|
|
153
|
+
let resolve = () => { };
|
|
154
|
+
const finished = new Promise((r) => {
|
|
155
|
+
resolve = r;
|
|
156
|
+
});
|
|
157
|
+
const settle = (outcome) => {
|
|
158
|
+
if (settled)
|
|
159
|
+
return;
|
|
160
|
+
settled = true;
|
|
161
|
+
onFinish?.(outcome);
|
|
162
|
+
resolve(outcome);
|
|
163
|
+
};
|
|
164
|
+
const stopAll = (outcome) => {
|
|
165
|
+
settle(outcome);
|
|
166
|
+
for (const animation of animations) {
|
|
167
|
+
try {
|
|
168
|
+
animation.cancel();
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// A cancelled animation stays cancelled.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
if (animations.length === 0) {
|
|
176
|
+
settle('instant');
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
let remaining = animations.length;
|
|
180
|
+
for (const animation of animations) {
|
|
181
|
+
animation.finished.then(() => {
|
|
182
|
+
remaining -= 1;
|
|
183
|
+
if (remaining === 0)
|
|
184
|
+
settle('finished');
|
|
185
|
+
}, () => settle('cancelled'));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
finished,
|
|
190
|
+
animations,
|
|
191
|
+
instantReason: animations.length === 0 ? (instantReason ?? 'zero-duration') : null,
|
|
192
|
+
cancel: () => stopAll('cancelled'),
|
|
193
|
+
interrupt: () => stopAll('interrupted'),
|
|
194
|
+
finish() {
|
|
195
|
+
if (settled)
|
|
196
|
+
return;
|
|
197
|
+
for (const animation of animations) {
|
|
198
|
+
try {
|
|
199
|
+
animation.finish();
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// An animation that cannot finish is cancelled below.
|
|
203
|
+
animation.cancel();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
settle('finished');
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Animate one element through the Web Animations API with the motion level
|
|
212
|
+
* and reduced motion applied.
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* const run = animate(card, [{ opacity: 0 }, { opacity: 1 }], { duration: 150 });
|
|
216
|
+
* await run.finished; // "finished", or "instant" under reduced motion
|
|
217
|
+
*/
|
|
218
|
+
export function animate(element, keyframes, options = {}) {
|
|
219
|
+
const plan = resolveMotion(element, keyframes, options);
|
|
220
|
+
if (plan.instant || !element) {
|
|
221
|
+
return createRun([], options.onFinish, plan.instant ? plan.reason : 'server');
|
|
222
|
+
}
|
|
223
|
+
const animation = startAnimation(element, plan);
|
|
224
|
+
return createRun(animation ? [animation] : [], options.onFinish, animation ? null : 'error');
|
|
225
|
+
}
|