tegaki 0.5.0 → 0.6.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/CHANGELOG.md +6 -0
- package/README.md +33 -160
- package/dist/core/index.d.mts +2 -0
- package/dist/core/index.mjs +2 -0
- package/dist/core-aZknK_0L.mjs +1121 -0
- package/dist/core-aZknK_0L.mjs.map +1 -0
- package/dist/index-BUNd9bnS.d.mts +39 -0
- package/dist/index-Bzxy01Zq.d.mts +329 -0
- package/dist/index.d.mts +3 -288
- package/dist/index.mjs +3 -922
- package/dist/react/index.d.mts +3 -0
- package/dist/react/index.mjs +3 -0
- package/dist/react-Ddx6VfJc.mjs +53 -0
- package/dist/react-Ddx6VfJc.mjs.map +1 -0
- package/dist/solid/index.d.mts +19 -0
- package/dist/solid/index.mjs +77 -0
- package/dist/solid/index.mjs.map +1 -0
- package/package.json +55 -4
- package/src/astro/TegakiRenderer.astro +126 -0
- package/src/core/engine.ts +878 -0
- package/src/core/index.ts +14 -0
- package/src/env.d.ts +12 -0
- package/src/index.ts +1 -12
- package/src/lib/css-properties.ts +24 -0
- package/src/lib/font.ts +29 -0
- package/src/react/TegakiRenderer.tsx +98 -0
- package/src/react/index.ts +2 -0
- package/src/solid/TegakiRenderer.tsx +99 -0
- package/src/solid/index.ts +2 -0
- package/src/svelte/TegakiRenderer.svelte +90 -0
- package/src/svelte/index.ts +2 -0
- package/src/vue/TegakiRenderer.vue +95 -0
- package/src/vue/index.ts +2 -0
- package/dist/index.mjs.map +0 -1
- package/src/lib/TegakiRenderer.tsx +0 -629
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { drawGlyph } from '../lib/drawGlyph.ts';
|
|
2
|
+
export { type ResolvedEffect, resolveEffects } from '../lib/effects.ts';
|
|
3
|
+
export { ensureFontFace } from '../lib/font.ts';
|
|
4
|
+
export { computeTextLayout, type TextLayout } from '../lib/textLayout.ts';
|
|
5
|
+
export { computeTimeline, type Timeline, type TimelineConfig, type TimelineEntry } from '../lib/timeline.ts';
|
|
6
|
+
export type * from '../types.ts';
|
|
7
|
+
export type { TegakiEffectConfigs, TegakiEffects } from '../types.ts';
|
|
8
|
+
export {
|
|
9
|
+
type CreateElementFn,
|
|
10
|
+
TegakiEngine,
|
|
11
|
+
type TegakiEngineOptions,
|
|
12
|
+
type TimeControlMode,
|
|
13
|
+
type TimeControlProp,
|
|
14
|
+
} from './engine.ts';
|
package/src/env.d.ts
CHANGED
|
@@ -2,3 +2,15 @@ declare module '*.ttf' {
|
|
|
2
2
|
const src: string;
|
|
3
3
|
export default src;
|
|
4
4
|
}
|
|
5
|
+
|
|
6
|
+
declare module '*.svelte' {
|
|
7
|
+
import type { Component } from 'svelte';
|
|
8
|
+
const component: Component<any>;
|
|
9
|
+
export default component;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
declare module '*.vue' {
|
|
13
|
+
import type { DefineComponent } from 'vue';
|
|
14
|
+
const component: DefineComponent;
|
|
15
|
+
export default component;
|
|
16
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1 @@
|
|
|
1
|
-
export
|
|
2
|
-
export {
|
|
3
|
-
ensureFontFace,
|
|
4
|
-
TegakiRenderer,
|
|
5
|
-
type TegakiRendererHandle,
|
|
6
|
-
type TegakiRendererProps,
|
|
7
|
-
type TimeControlMode,
|
|
8
|
-
type TimeControlProp,
|
|
9
|
-
} from './lib/TegakiRenderer.tsx';
|
|
10
|
-
export { computeTimeline, type Timeline, type TimelineConfig } from './lib/timeline.ts';
|
|
11
|
-
export type * from './types.ts';
|
|
12
|
-
export type { TegakiEffectConfigs, TegakiEffects } from './types.ts';
|
|
1
|
+
export * from './react/index.ts';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const CSS_TIME = '--tegaki-time';
|
|
2
|
+
export const CSS_PROGRESS = '--tegaki-progress';
|
|
3
|
+
export const CSS_DURATION = '--tegaki-duration';
|
|
4
|
+
|
|
5
|
+
export const PADDING_H_EM = 0.2;
|
|
6
|
+
export const MIN_LINE_HEIGHT_EM = 1.8;
|
|
7
|
+
export const MIN_PADDING_V_EM = 0.2;
|
|
8
|
+
|
|
9
|
+
// Register custom properties so they are animatable (typed as <number>).
|
|
10
|
+
// Deferred to first use to avoid running at import time during SSR.
|
|
11
|
+
let cssPropertiesRegistered = false;
|
|
12
|
+
export function registerCssProperties() {
|
|
13
|
+
if (cssPropertiesRegistered) return;
|
|
14
|
+
cssPropertiesRegistered = true;
|
|
15
|
+
if (typeof CSS !== 'undefined' && 'registerProperty' in CSS) {
|
|
16
|
+
for (const prop of [CSS_TIME, CSS_PROGRESS, CSS_DURATION]) {
|
|
17
|
+
try {
|
|
18
|
+
CSS.registerProperty({ name: prop, syntax: '<number>', inherits: true, initialValue: '0' });
|
|
19
|
+
} catch {
|
|
20
|
+
// Already registered — ignore.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/lib/font.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { TegakiBundle } from '../types.ts';
|
|
2
|
+
|
|
3
|
+
const fontFaceCache = new Map<string, Promise<void>>();
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Ensures the bundle's font face is loaded and available for rendering.
|
|
7
|
+
* Resolves immediately if the font is already loaded.
|
|
8
|
+
*/
|
|
9
|
+
export async function ensureFontFace(bundle: TegakiBundle): Promise<void> {
|
|
10
|
+
await ensureFont(bundle.family, bundle.fontUrl);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ensureFont(family: string, url: string): Promise<void> | null {
|
|
14
|
+
if (typeof document === 'undefined') return Promise.resolve();
|
|
15
|
+
for (const face of document.fonts) {
|
|
16
|
+
if (face.family === family) {
|
|
17
|
+
if (face.status === 'loaded') return null;
|
|
18
|
+
if (face.status === 'loading') return face.loaded.then(() => {});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
let cached = fontFaceCache.get(url);
|
|
22
|
+
if (!cached) {
|
|
23
|
+
cached = new FontFace(family, `url(${url})`, { featureSettings: "'calt' 0, 'liga' 0" }).load().then((loaded) => {
|
|
24
|
+
document.fonts.add(loaded);
|
|
25
|
+
});
|
|
26
|
+
fontFaceCache.set(url, cached);
|
|
27
|
+
}
|
|
28
|
+
return cached;
|
|
29
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type ComponentProps, createElement, type ReactNode, type Ref, useEffect, useImperativeHandle, useRef } from 'react';
|
|
4
|
+
import { TegakiEngine, type TegakiEngineOptions } from '../core/engine.ts';
|
|
5
|
+
import type { Coercible } from '../lib/utils.ts';
|
|
6
|
+
import { coerceToString } from '../lib/utils.ts';
|
|
7
|
+
import type { TegakiEffects } from '../types.ts';
|
|
8
|
+
|
|
9
|
+
/** Imperative handle exposed via the `ref` prop. */
|
|
10
|
+
export interface TegakiRendererHandle {
|
|
11
|
+
/** The underlying engine instance. `null` before mount and after unmount. */
|
|
12
|
+
readonly engine: TegakiEngine | null;
|
|
13
|
+
/** The container DOM element. */
|
|
14
|
+
readonly element: HTMLDivElement | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TegakiRendererProps<E extends TegakiEffects<E> = Record<string, never>>
|
|
18
|
+
extends Omit<TegakiEngineOptions, 'effects'>,
|
|
19
|
+
Omit<ComponentProps<'div'>, 'children' | 'ref'> {
|
|
20
|
+
/** Imperative handle ref for playback controls and DOM access. */
|
|
21
|
+
ref?: Ref<TegakiRendererHandle>;
|
|
22
|
+
|
|
23
|
+
/** Children coerced to string. Strings and numbers are kept; everything else is ignored. */
|
|
24
|
+
children?: Coercible;
|
|
25
|
+
|
|
26
|
+
/** Visual effects applied during canvas rendering. */
|
|
27
|
+
effects?: E;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function reactCreateElement(tag: string, props: Record<string, any>, ...children: (ReactNode | string)[]): ReactNode {
|
|
31
|
+
return createElement(tag, { ...props, key: props['data-tegaki'] }, ...children);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function TegakiRenderer<const E extends TegakiEffects<E> = Record<string, never>>({
|
|
35
|
+
ref,
|
|
36
|
+
font,
|
|
37
|
+
text,
|
|
38
|
+
children,
|
|
39
|
+
time: timeProp,
|
|
40
|
+
onComplete,
|
|
41
|
+
effects,
|
|
42
|
+
segmentSize,
|
|
43
|
+
timing,
|
|
44
|
+
showOverlay,
|
|
45
|
+
...divProps
|
|
46
|
+
}: TegakiRendererProps<E>) {
|
|
47
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
48
|
+
const engineRef = useRef<TegakiEngine | null>(null);
|
|
49
|
+
const resolvedText = text ?? coerceToString(children);
|
|
50
|
+
|
|
51
|
+
// Render the element tree via the engine's static method (SSR-safe)
|
|
52
|
+
const engineOptions: TegakiEngineOptions = {
|
|
53
|
+
text: resolvedText,
|
|
54
|
+
font,
|
|
55
|
+
time: timeProp,
|
|
56
|
+
effects: effects as Record<string, any>,
|
|
57
|
+
segmentSize,
|
|
58
|
+
timing,
|
|
59
|
+
showOverlay,
|
|
60
|
+
onComplete,
|
|
61
|
+
};
|
|
62
|
+
const elements = TegakiEngine.renderElements(engineOptions, reactCreateElement);
|
|
63
|
+
|
|
64
|
+
// Create engine on mount, adopting the pre-rendered elements
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const engine = new TegakiEngine(containerRef.current!, { adopt: true });
|
|
67
|
+
engineRef.current = engine;
|
|
68
|
+
return () => {
|
|
69
|
+
engine.destroy();
|
|
70
|
+
engineRef.current = null;
|
|
71
|
+
};
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
// Update engine with current props every render
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
engineRef.current?.update(engineOptions);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Imperative handle
|
|
80
|
+
useImperativeHandle(
|
|
81
|
+
ref,
|
|
82
|
+
() => ({
|
|
83
|
+
get engine() {
|
|
84
|
+
return engineRef.current;
|
|
85
|
+
},
|
|
86
|
+
get element() {
|
|
87
|
+
return containerRef.current;
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
[],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div ref={containerRef} {...divProps}>
|
|
95
|
+
{elements}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** @jsxImportSource solid-js */
|
|
2
|
+
import { createEffect, createMemo, type JSX, on, onCleanup, onMount, splitProps } from 'solid-js';
|
|
3
|
+
import { TegakiEngine, type TegakiEngineOptions } from '../core/engine.ts';
|
|
4
|
+
import type { TegakiEffects } from '../types.ts';
|
|
5
|
+
|
|
6
|
+
export interface TegakiRendererProps extends Omit<TegakiEngineOptions, 'effects'> {
|
|
7
|
+
/** Visual effects applied during canvas rendering. */
|
|
8
|
+
effects?: TegakiEffects<Record<string, any>>;
|
|
9
|
+
class?: string;
|
|
10
|
+
ref?: (handle: TegakiRendererHandle) => void;
|
|
11
|
+
[key: string]: any;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TegakiRendererHandle {
|
|
15
|
+
readonly engine: TegakiEngine | null;
|
|
16
|
+
readonly element: HTMLDivElement | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function solidCreateElement(tag: string, props: Record<string, any>, ...children: (JSX.Element | string)[]): JSX.Element {
|
|
20
|
+
const parts: string[] = [];
|
|
21
|
+
for (const [key, value] of Object.entries(props)) {
|
|
22
|
+
if (value == null || value === false) continue;
|
|
23
|
+
if (key === 'style' && typeof value === 'object') {
|
|
24
|
+
const css = Object.entries(value)
|
|
25
|
+
.filter(([, v]) => v != null)
|
|
26
|
+
.map(([k, v]) => {
|
|
27
|
+
const prop = k.startsWith('--') ? k : k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
|
|
28
|
+
const val = typeof v === 'number' && !k.startsWith('--') ? `${v}px` : String(v);
|
|
29
|
+
return `${prop}:${val}`;
|
|
30
|
+
})
|
|
31
|
+
.join(';');
|
|
32
|
+
if (css) parts.push(`style="${escapeAttr(css)}"`);
|
|
33
|
+
} else if (typeof value === 'boolean') {
|
|
34
|
+
parts.push(key);
|
|
35
|
+
} else {
|
|
36
|
+
parts.push(`${key}="${escapeAttr(String(value))}"`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const open = parts.length > 0 ? `<${tag} ${parts.join(' ')}>` : `<${tag}>`;
|
|
40
|
+
const content = children.map((c) => (typeof c === 'string' && !c.startsWith('<') ? escapeHtml(c) : (c as string))).join('');
|
|
41
|
+
return `${open}${content}</${tag}>` as unknown as JSX.Element;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function escapeAttr(s: string): string {
|
|
45
|
+
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function escapeHtml(s: string): string {
|
|
49
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function TegakiRenderer(props: TegakiRendererProps) {
|
|
53
|
+
const [local, divProps] = splitProps(props, [
|
|
54
|
+
'text',
|
|
55
|
+
'font',
|
|
56
|
+
'time',
|
|
57
|
+
'onComplete',
|
|
58
|
+
'effects',
|
|
59
|
+
'segmentSize',
|
|
60
|
+
'timing',
|
|
61
|
+
'showOverlay',
|
|
62
|
+
'ref',
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
let container!: HTMLDivElement;
|
|
66
|
+
let engine: TegakiEngine | null = null;
|
|
67
|
+
|
|
68
|
+
const engineOptions = createMemo<TegakiEngineOptions>(() => ({
|
|
69
|
+
text: local.text,
|
|
70
|
+
font: local.font,
|
|
71
|
+
time: local.time,
|
|
72
|
+
effects: local.effects as Record<string, any>,
|
|
73
|
+
segmentSize: local.segmentSize,
|
|
74
|
+
timing: local.timing,
|
|
75
|
+
showOverlay: local.showOverlay,
|
|
76
|
+
onComplete: local.onComplete,
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
// Compute initial HTML once — after the engine adopts, all updates go through engine.update().
|
|
80
|
+
const innerHTML = TegakiEngine.renderElements(engineOptions(), solidCreateElement) as unknown as string;
|
|
81
|
+
|
|
82
|
+
onMount(() => {
|
|
83
|
+
engine = new TegakiEngine(container, { ...engineOptions(), adopt: true });
|
|
84
|
+
local.ref?.({ engine, element: container });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
onCleanup(() => {
|
|
88
|
+
engine?.destroy();
|
|
89
|
+
engine = null;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
createEffect(
|
|
93
|
+
on(engineOptions, (options) => {
|
|
94
|
+
engine?.update(options);
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return <div ref={container!} {...divProps} innerHTML={innerHTML} />;
|
|
99
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte';
|
|
3
|
+
import { TegakiEngine, type TegakiEngineOptions } from '../core/engine.ts';
|
|
4
|
+
import type { TegakiEffects } from '../types.ts';
|
|
5
|
+
|
|
6
|
+
interface Props extends Omit<TegakiEngineOptions, 'effects'> {
|
|
7
|
+
/** Visual effects applied during canvas rendering. */
|
|
8
|
+
effects?: TegakiEffects<Record<string, any>>;
|
|
9
|
+
class?: string;
|
|
10
|
+
[key: string]: any;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// biome-ignore lint/correctness/noUnusedVariables: attrs is used in Svelte template
|
|
14
|
+
let { text, font, time: timeProp, onComplete, effects, segmentSize, timing, showOverlay, class: className, ...attrs }: Props = $props();
|
|
15
|
+
|
|
16
|
+
let container = $state<HTMLDivElement | undefined>();
|
|
17
|
+
let engine = $state<TegakiEngine | null>(null);
|
|
18
|
+
|
|
19
|
+
const engineOptions: TegakiEngineOptions = $derived({
|
|
20
|
+
text,
|
|
21
|
+
font,
|
|
22
|
+
time: timeProp,
|
|
23
|
+
effects: effects as Record<string, any>,
|
|
24
|
+
segmentSize,
|
|
25
|
+
timing,
|
|
26
|
+
showOverlay,
|
|
27
|
+
onComplete,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function svelteCreateElement(tag: string, props: Record<string, any>, ...children: string[]): string {
|
|
31
|
+
const parts: string[] = [];
|
|
32
|
+
for (const [key, value] of Object.entries(props)) {
|
|
33
|
+
if (value == null || value === false) continue;
|
|
34
|
+
if (key === 'style' && typeof value === 'object') {
|
|
35
|
+
const css = Object.entries(value)
|
|
36
|
+
.filter(([, v]) => v != null)
|
|
37
|
+
.map(([k, v]) => {
|
|
38
|
+
const prop = k.startsWith('--') ? k : k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
|
|
39
|
+
const val = typeof v === 'number' && !k.startsWith('--') ? `${v}px` : String(v);
|
|
40
|
+
return `${prop}:${val}`;
|
|
41
|
+
})
|
|
42
|
+
.join(';');
|
|
43
|
+
if (css) parts.push(`style="${escapeAttr(css)}"`);
|
|
44
|
+
} else if (typeof value === 'boolean') {
|
|
45
|
+
parts.push(key);
|
|
46
|
+
} else {
|
|
47
|
+
parts.push(`${key}="${escapeAttr(String(value))}"`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const open = parts.length > 0 ? `<${tag} ${parts.join(' ')}>` : `<${tag}>`;
|
|
51
|
+
const content = children.map((c) => (typeof c === 'string' && !c.startsWith('<') ? escapeHtml(c) : c)).join('');
|
|
52
|
+
return `${open}${content}</${tag}>`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function escapeAttr(s: string): string {
|
|
56
|
+
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function escapeHtml(s: string): string {
|
|
60
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Compute initial HTML once — after the engine adopts, all updates go through engine.update().
|
|
64
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in Svelte template
|
|
65
|
+
const innerHtml: string = TegakiEngine.renderElements(
|
|
66
|
+
{ text, font, time: timeProp, effects: effects as Record<string, any>, segmentSize, timing, showOverlay, onComplete },
|
|
67
|
+
svelteCreateElement,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
$effect(() => {
|
|
71
|
+
if (!container) return;
|
|
72
|
+
// Read engineOptions outside tracking to avoid re-running this effect on prop changes.
|
|
73
|
+
// Prop updates are handled by the separate engine.update() effect below.
|
|
74
|
+
const opts = untrack(() => engineOptions);
|
|
75
|
+
const e = new TegakiEngine(container, { ...opts, adopt: true });
|
|
76
|
+
engine = e;
|
|
77
|
+
return () => {
|
|
78
|
+
e.destroy();
|
|
79
|
+
engine = null;
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
$effect(() => {
|
|
84
|
+
engine?.update(engineOptions);
|
|
85
|
+
});
|
|
86
|
+
</script>
|
|
87
|
+
|
|
88
|
+
<div bind:this={container} class={className} {...attrs}>
|
|
89
|
+
{@html innerHtml}
|
|
90
|
+
</div>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
|
3
|
+
import { TegakiEngine, type TegakiEngineOptions } from '../core/engine.ts';
|
|
4
|
+
import type { TegakiBundle, TegakiEffects } from '../types.ts';
|
|
5
|
+
import type { TimeControlProp } from '../core/engine.ts';
|
|
6
|
+
import type { TimelineConfig } from '../lib/timeline.ts';
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
text?: string;
|
|
10
|
+
font?: TegakiBundle | string;
|
|
11
|
+
time?: TimeControlProp;
|
|
12
|
+
effects?: TegakiEffects<Record<string, any>>;
|
|
13
|
+
timing?: TimelineConfig;
|
|
14
|
+
segmentSize?: number;
|
|
15
|
+
showOverlay?: boolean;
|
|
16
|
+
onComplete?: () => void;
|
|
17
|
+
}>();
|
|
18
|
+
|
|
19
|
+
defineOptions({ inheritAttrs: false });
|
|
20
|
+
|
|
21
|
+
const container = ref<HTMLDivElement>();
|
|
22
|
+
let engine: TegakiEngine | null = null;
|
|
23
|
+
|
|
24
|
+
const engineOptions = computed<TegakiEngineOptions>(() => ({
|
|
25
|
+
text: props.text,
|
|
26
|
+
font: props.font,
|
|
27
|
+
time: props.time,
|
|
28
|
+
effects: props.effects as Record<string, any>,
|
|
29
|
+
segmentSize: props.segmentSize,
|
|
30
|
+
timing: props.timing,
|
|
31
|
+
showOverlay: props.showOverlay,
|
|
32
|
+
onComplete: props.onComplete,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
function escapeAttr(s: string): string {
|
|
36
|
+
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function escapeHtml(s: string): string {
|
|
40
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function htmlCreateElement(tag: string, nodeProps: Record<string, any>, ...children: string[]): string {
|
|
44
|
+
const parts: string[] = [];
|
|
45
|
+
for (const [key, value] of Object.entries(nodeProps)) {
|
|
46
|
+
if (value == null || value === false) continue;
|
|
47
|
+
if (key === 'style' && typeof value === 'object') {
|
|
48
|
+
const css = Object.entries(value)
|
|
49
|
+
.filter(([, v]) => v != null)
|
|
50
|
+
.map(([k, v]) => {
|
|
51
|
+
const prop = k.startsWith('--') ? k : k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
|
|
52
|
+
const val = typeof v === 'number' && !k.startsWith('--') ? `${v}px` : String(v);
|
|
53
|
+
return `${prop}:${val}`;
|
|
54
|
+
})
|
|
55
|
+
.join(';');
|
|
56
|
+
if (css) parts.push(`style="${escapeAttr(css)}"`);
|
|
57
|
+
} else if (typeof value === 'boolean') {
|
|
58
|
+
parts.push(key);
|
|
59
|
+
} else {
|
|
60
|
+
parts.push(`${key}="${escapeAttr(String(value))}"`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const open = parts.length > 0 ? `<${tag} ${parts.join(' ')}>` : `<${tag}>`;
|
|
64
|
+
const content = children.map((c) => (typeof c === 'string' && !c.startsWith('<') ? escapeHtml(c) : c)).join('');
|
|
65
|
+
return `${open}${content}</${tag}>`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Compute initial HTML once — after the engine adopts, all updates go through engine.update().
|
|
69
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in Vue template
|
|
70
|
+
const innerHtml = TegakiEngine.renderElements(engineOptions.value, htmlCreateElement);
|
|
71
|
+
|
|
72
|
+
onMounted(() => {
|
|
73
|
+
if (!container.value) return;
|
|
74
|
+
engine = new TegakiEngine(container.value, { ...engineOptions.value, adopt: true });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
onUnmounted(() => {
|
|
78
|
+
engine?.destroy();
|
|
79
|
+
engine = null;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
watch(
|
|
83
|
+
engineOptions,
|
|
84
|
+
(options) => {
|
|
85
|
+
engine?.update(options);
|
|
86
|
+
},
|
|
87
|
+
{ deep: true },
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
defineExpose({ engine, element: container });
|
|
91
|
+
</script>
|
|
92
|
+
|
|
93
|
+
<template>
|
|
94
|
+
<div ref="container" v-bind="$attrs" v-html="innerHtml" />
|
|
95
|
+
</template>
|
package/src/vue/index.ts
ADDED