@vectoriox/iox-ui 5.71.1 → 5.72.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/fesm2022/vectoriox-iox-ui-actions-core.mjs +15 -0
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-scene-core.mjs +285 -11
- package/fesm2022/vectoriox-iox-ui-scene-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui.mjs +58 -6
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/index.d.ts +26 -4
- package/package.json +1 -1
- package/scene-core/index.d.ts +93 -3
- package/src/actions-core/element-actions.ts +15 -0
- package/src/scene-core/public-api.ts +1 -0
- package/src/scene-core/scene-edit.ts +19 -3
- package/src/scene-core/scene.ts +67 -5
- package/src/scene-core/scroll-driver.ts +52 -3
- package/src/scene-core/split-text.ts +187 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text into pieces, in the browser only (premium sites SC3, Motion v2 §3c). A saved node keeps its plain text and the
|
|
3
|
+
* server sends it whole, so every word is in the raw response, editing never meets a piece, and there is no markup for
|
|
4
|
+
* hydration to disagree with. Each original text node is kept and put back as it was, never rebuilt from a string: a
|
|
5
|
+
* framework may hold that node, and a layer may hold live components.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export enum TextRevealBy {
|
|
9
|
+
Line = 'line',
|
|
10
|
+
Word = 'word',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Past these a text is plainly no heading: it fades whole, and lights up whole. */
|
|
14
|
+
export const REVEAL_PIECE_CAP = 120;
|
|
15
|
+
export const LIGHT_UP_PIECE_CAP = 300;
|
|
16
|
+
|
|
17
|
+
const SKIPPED_TAGS: ReadonlySet<string> = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'SELECT', 'OPTION']);
|
|
18
|
+
const SHOW_TEXT = 4;
|
|
19
|
+
/** Hebrew through Arabic Extended, and their presentation forms. */
|
|
20
|
+
const RTL_LETTER = /[-ࣿיִ-﷿ﹰ-]/;
|
|
21
|
+
|
|
22
|
+
export interface TextPiece {
|
|
23
|
+
text: string;
|
|
24
|
+
/** False for the space between two words, which stays bare text so lines wrap exactly as they did. */
|
|
25
|
+
word: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A string as words and the space between them. With `keepLtrRuns` a run of words holding no RTL letter stays ONE word:
|
|
30
|
+
* an inline-block is neutral to the bidi algorithm, so "Studio Alma" inside a Hebrew heading, split, would read reversed.
|
|
31
|
+
*/
|
|
32
|
+
export function textPieces(text: string, keepLtrRuns = false): TextPiece[] {
|
|
33
|
+
const pieces: TextPiece[] = (text.match(/\s+|\S+/g) ?? []).map(part => ({ text: part, word: /\S/.test(part) }));
|
|
34
|
+
if (!keepLtrRuns) return pieces;
|
|
35
|
+
const merged: TextPiece[] = [];
|
|
36
|
+
for (const piece of pieces) {
|
|
37
|
+
const space = merged[merged.length - 1];
|
|
38
|
+
const word = merged[merged.length - 2];
|
|
39
|
+
if (piece.word && !RTL_LETTER.test(piece.text) && space && !space.word && word?.word && !RTL_LETTER.test(word.text)) {
|
|
40
|
+
word.text += space.text + piece.text;
|
|
41
|
+
merged.pop();
|
|
42
|
+
} else {
|
|
43
|
+
merged.push({ ...piece });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return merged;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SplitTextOptions {
|
|
50
|
+
cap: number;
|
|
51
|
+
/** Each piece in a clipping mask, to rise out of (and so an inline-block). Without it a piece is a plain inline span. */
|
|
52
|
+
mask?: boolean;
|
|
53
|
+
/** The element reads right-to-left. Only a mask needs to know. */
|
|
54
|
+
rtl?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SplitText {
|
|
58
|
+
/** In reading order. With a mask, the moving part inside it. */
|
|
59
|
+
pieces: HTMLElement[];
|
|
60
|
+
/** Put every original text node back. Safe after the text was replaced: what is gone stays gone. */
|
|
61
|
+
restore(): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Replacement {
|
|
65
|
+
original: Text;
|
|
66
|
+
nodes: Node[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function textNodesOf(root: HTMLElement): Text[] {
|
|
70
|
+
const walker = root.ownerDocument.createTreeWalker(root, SHOW_TEXT);
|
|
71
|
+
const found: Text[] = [];
|
|
72
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
73
|
+
const parent = node.parentElement;
|
|
74
|
+
if (parent && !SKIPPED_TAGS.has(parent.tagName) && /\S/.test(node.nodeValue ?? '')) found.push(node as Text);
|
|
75
|
+
}
|
|
76
|
+
return found;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function pieceOf(doc: Document, text: string, mask: boolean): { outer: HTMLElement; moving: HTMLElement } {
|
|
80
|
+
const moving = doc.createElement('span');
|
|
81
|
+
moving.textContent = text;
|
|
82
|
+
if (!mask) return { outer: moving, moving };
|
|
83
|
+
moving.style.display = 'inline-block';
|
|
84
|
+
const outer = doc.createElement('span');
|
|
85
|
+
outer.style.display = 'inline-block';
|
|
86
|
+
// A clip-path and not overflow, which would move the box's baseline. The negative sides leave descenders and italics.
|
|
87
|
+
outer.style.clipPath = 'inset(-0.1em -0.1em -0.18em -0.1em)';
|
|
88
|
+
outer.appendChild(moving);
|
|
89
|
+
return { outer, moving };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Split `root`'s text, or null when it holds none or more than `cap` pieces (nothing is changed then). */
|
|
93
|
+
export function splitText(root: HTMLElement, options: SplitTextOptions): SplitText | null {
|
|
94
|
+
const doc = root.ownerDocument;
|
|
95
|
+
const mask = !!options.mask;
|
|
96
|
+
const planned = textNodesOf(root).map(original => ({ original, parts: textPieces(original.nodeValue ?? '', mask && !!options.rtl) }));
|
|
97
|
+
const count = planned.reduce((sum, { parts }) => sum + parts.filter(part => part.word).length, 0);
|
|
98
|
+
if (!count || count > options.cap) return null;
|
|
99
|
+
|
|
100
|
+
const pieces: HTMLElement[] = [];
|
|
101
|
+
const replacements: Replacement[] = planned.map(({ original, parts }) => {
|
|
102
|
+
const nodes = parts.map((part): Node => {
|
|
103
|
+
if (!part.word) return doc.createTextNode(part.text);
|
|
104
|
+
const { outer, moving } = pieceOf(doc, part.text, mask);
|
|
105
|
+
pieces.push(moving);
|
|
106
|
+
return outer;
|
|
107
|
+
});
|
|
108
|
+
const parent = original.parentNode as ParentNode & Node;
|
|
109
|
+
for (const node of nodes) parent.insertBefore(node, original);
|
|
110
|
+
parent.removeChild(original);
|
|
111
|
+
return { original, nodes };
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
pieces,
|
|
116
|
+
restore: () => {
|
|
117
|
+
for (const { original, nodes } of replacements) {
|
|
118
|
+
const first = nodes[0];
|
|
119
|
+
if (!first?.isConnected || !first.parentNode) continue;
|
|
120
|
+
first.parentNode.insertBefore(original, first);
|
|
121
|
+
for (const node of nodes) node.parentNode?.removeChild(node);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The line each piece stands on: 0, 0, 1, 1, 1, 2… Read once, as a reveal starts. */
|
|
128
|
+
export function lineIndexes(tops: ReadonlyArray<number>, heights: ReadonlyArray<number>): number[] {
|
|
129
|
+
let line = 0;
|
|
130
|
+
let top = tops[0] ?? 0;
|
|
131
|
+
return tops.map((next, i) => {
|
|
132
|
+
if (next - top > (heights[i] ?? 0) / 2) {
|
|
133
|
+
line++;
|
|
134
|
+
top = next;
|
|
135
|
+
}
|
|
136
|
+
return line;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const LINE_STAGGER_MS = 90;
|
|
141
|
+
const WORD_STAGGER_MS = 40;
|
|
142
|
+
/** However long the heading, the last piece starts within this. */
|
|
143
|
+
const STAGGER_BUDGET_MS = 1000;
|
|
144
|
+
|
|
145
|
+
export function revealStagger(groups: number, by: TextRevealBy): number {
|
|
146
|
+
const step = by === TextRevealBy.Line ? LINE_STAGGER_MS : WORD_STAGGER_MS;
|
|
147
|
+
return groups > 1 ? Math.min(step, STAGGER_BUDGET_MS / (groups - 1)) : 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Past the mask's negative inset, so nothing shows before a piece's turn. */
|
|
151
|
+
const PIECE_FRAMES: Keyframe[] = [{ transform: 'translateY(120%)' }, { transform: 'translateY(0)' }];
|
|
152
|
+
const DEFAULT_PIECE_MS = 700;
|
|
153
|
+
|
|
154
|
+
const playing = new WeakMap<HTMLElement, Animation>();
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Play a text reveal: each piece rises out of its mask, line by line or word by word. Returns ONE animation for the
|
|
158
|
+
* whole, so a runtime treats it as any other: it finishes when the last piece lands, and on finish or cancel the text
|
|
159
|
+
* is put back whole, which leaves nothing for a screen reader to stumble on and nothing to re-split on a resize. Null
|
|
160
|
+
* when there is nothing to split or it is being edited: the caller plays its plain fade.
|
|
161
|
+
*/
|
|
162
|
+
export function playTextReveal(el: HTMLElement, by: TextRevealBy, timing: KeyframeAnimationOptions): Animation | null {
|
|
163
|
+
playing.get(el)?.cancel();
|
|
164
|
+
if (el.isContentEditable) return null;
|
|
165
|
+
const rtl = el.ownerDocument.defaultView?.getComputedStyle(el).direction === 'rtl';
|
|
166
|
+
const split = splitText(el, { cap: REVEAL_PIECE_CAP, mask: true, rtl });
|
|
167
|
+
if (!split) return null;
|
|
168
|
+
|
|
169
|
+
const boxes = by === TextRevealBy.Line ? split.pieces.map(piece => (piece.parentElement as HTMLElement).getBoundingClientRect()) : [];
|
|
170
|
+
const order = by === TextRevealBy.Line ? lineIndexes(boxes.map(box => box.top), boxes.map(box => box.height)) : split.pieces.map((_, i) => i);
|
|
171
|
+
const groups = Math.max(...order) + 1;
|
|
172
|
+
const step = revealStagger(groups, by);
|
|
173
|
+
const duration = Number(timing.duration) || DEFAULT_PIECE_MS;
|
|
174
|
+
const delay = timing.delay ?? 0;
|
|
175
|
+
const pieces = split.pieces.map((piece, i) => piece.animate(PIECE_FRAMES, { duration, delay: delay + order[i] * step, easing: timing.easing, fill: 'both' }));
|
|
176
|
+
// The element itself just stands visible: its entrance's first frame had hidden it whole.
|
|
177
|
+
const whole = el.animate([{ opacity: 1 }, { opacity: 1 }], { duration: delay + duration + step * (groups - 1), fill: timing.fill ?? 'both' });
|
|
178
|
+
playing.set(el, whole);
|
|
179
|
+
const finished = (): void => {
|
|
180
|
+
if (playing.get(el) === whole) playing.delete(el);
|
|
181
|
+
for (const piece of pieces) piece.cancel();
|
|
182
|
+
split.restore();
|
|
183
|
+
};
|
|
184
|
+
whole.addEventListener('finish', finished, { once: true });
|
|
185
|
+
whole.addEventListener('cancel', finished, { once: true });
|
|
186
|
+
return whole;
|
|
187
|
+
}
|