feat: Kinetic Text Tags {bt} {sc} {fi} {rotat} move letters; closing tags close their own tag
Letters of these custom tags animate one after another (CSS). A closing tag now closes the style that it opened (with anything left open inside) instead of popping whatever was on top, and an unknown tag that has a closing tag later shows its text plainly and stays balanced. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8251fca9bf
commit
dcddd42aca
3 files changed
+60
-6
No files matched your search
@@ -369,3 +369,14 @@
|
|||||||
/* checkbox row of the settings screen */
|
/* checkbox row of the settings screen */
|
||||||
.pg-check { display: flex; align-items: center; gap: 10px; margin: 4px 0 14px; cursor: pointer; font-size: 14px; }
|
.pg-check { display: flex; align-items: center; gap: 10px; margin: 4px 0 14px; cursor: pointer; font-size: 14px; }
|
||||||
.pg-check input { width: 18px; height: 18px; accent-color: var(--pg-accent, #f5a040); }
|
.pg-check input { width: 18px; height: 18px; accent-color: var(--pg-accent, #f5a040); }
|
||||||
|
|
||||||
|
/* Kinetic Text Tags: {bt}, {sc}, {fi}, {rotat} move each letter on its own */
|
||||||
|
.pg-fx { display: inline-block; white-space: pre; }
|
||||||
|
.pg-fx-bt { animation: pg-fx-bounce 0.9s ease-in-out infinite; }
|
||||||
|
.pg-fx-sc { animation: pg-fx-scare 0.12s linear infinite; }
|
||||||
|
.pg-fx-fi { animation: pg-fx-in 0.6s ease-out both; }
|
||||||
|
.pg-fx-rotat { animation: pg-fx-spin 2s linear infinite; }
|
||||||
|
@keyframes pg-fx-bounce { 50% { transform: translateY(calc(var(--fx, 6) * -1px)); } }
|
||||||
|
@keyframes pg-fx-scare { 25% { transform: translate(1px, -1px); } 75% { transform: translate(-1px, 1px); } }
|
||||||
|
@keyframes pg-fx-in { from { opacity: 0; transform: translateY(8px); } }
|
||||||
|
@keyframes pg-fx-spin { to { transform: rotate(360deg); } }
|
||||||
@@ -9,6 +9,11 @@ export interface Style {
|
|||||||
s?: boolean;
|
s?: boolean;
|
||||||
color?: string;
|
color?: string;
|
||||||
size?: string;
|
size?: string;
|
||||||
|
/** Per-letter motion from a custom tag: bt (bounce), sc (shake), fi (fade in), rotat (spin). */
|
||||||
|
fx?: string;
|
||||||
|
fxArg?: string;
|
||||||
|
/** The tag that opened this style, so its closing tag finds it again. */
|
||||||
|
tag?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Segment =
|
export type Segment =
|
||||||
@@ -74,7 +79,11 @@ export function parseText(text: string): ParsedText {
|
|||||||
const [name, value] = splitTag(tag);
|
const [name, value] = splitTag(tag);
|
||||||
const top = stack[stack.length - 1];
|
const top = stack[stack.length - 1];
|
||||||
if (name.startsWith("/")) {
|
if (name.startsWith("/")) {
|
||||||
if (stack.length > 1) stack.pop();
|
const opened = name.slice(1);
|
||||||
|
let at = stack.length - 1;
|
||||||
|
if (opened) while (at > 0 && stack[at].tag !== opened) at--;
|
||||||
|
if (opened && at > 0) stack.length = at; // closes what it opened, and anything left open inside
|
||||||
|
else if (!opened && stack.length > 1) stack.pop(); // `{/}` closes the innermost style
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
switch (name) {
|
switch (name) {
|
||||||
@@ -82,13 +91,16 @@ export function parseText(text: string): ParsedText {
|
|||||||
case "i":
|
case "i":
|
||||||
case "u":
|
case "u":
|
||||||
case "s":
|
case "s":
|
||||||
stack.push({ ...top, [name]: true });
|
stack.push({ ...top, [name]: true, tag: name });
|
||||||
break;
|
break;
|
||||||
case "color":
|
case "color":
|
||||||
stack.push({ ...top, color: value });
|
stack.push({ ...top, color: value, tag: name });
|
||||||
break;
|
break;
|
||||||
case "size":
|
case "size":
|
||||||
stack.push({ ...top, size: value });
|
stack.push({ ...top, size: value, tag: name });
|
||||||
|
break;
|
||||||
|
case "bt": case "sc": case "fi": case "rotat": // Kinetic Text Tags
|
||||||
|
stack.push({ ...top, fx: name, fxArg: value, tag: name });
|
||||||
break;
|
break;
|
||||||
case "w":
|
case "w":
|
||||||
segments.push({ kind: "wait", seconds: value ? Number(value) : null });
|
segments.push({ kind: "wait", seconds: value ? Number(value) : null });
|
||||||
@@ -111,10 +123,12 @@ export function parseText(text: string): ParsedText {
|
|||||||
break;
|
break;
|
||||||
case "a": case "font": case "cps": case "alpha": case "k": case "plain":
|
case "a": case "font": case "cps": case "alpha": case "k": case "plain":
|
||||||
case "outlinecolor": case "rt": case "rb": case "art": case "shader":
|
case "outlinecolor": case "rt": case "rb": case "art": case "shader":
|
||||||
stack.push({ ...top }); // styling we ignore but must still balance
|
stack.push({ ...top, tag: name }); // styling we ignore but must still balance
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// {image=...}, {#comment}, {done}, {clear} and unknown self-closing tags
|
// a tag with its closing tag later ({chaos}...{/chaos}) shows its text plainly; anything else
|
||||||
|
// ({image=...}, {#comment}, {done}, {clear}) is self-closing
|
||||||
|
if (name && text.includes(`{/${name}}`, end)) stack.push({ ...top, tag: name });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -142,6 +156,22 @@ export function renderText(el: HTMLElement, parsed: ParsedText, visible: number,
|
|||||||
const shown = chars.length <= left ? seg.text : chars.slice(0, left).join("");
|
const shown = chars.length <= left ? seg.text : chars.slice(0, left).join("");
|
||||||
left -= chars.length;
|
left -= chars.length;
|
||||||
const st = seg.style;
|
const st = seg.style;
|
||||||
|
if (st.fx) { // every letter moves on its own, one after another
|
||||||
|
const amount = Number.parseFloat(st.fxArg ?? "");
|
||||||
|
[...shown].forEach((letter, index) => {
|
||||||
|
const piece = document.createElement("span");
|
||||||
|
piece.textContent = letter;
|
||||||
|
piece.className = `pg-fx pg-fx-${st.fx}`;
|
||||||
|
piece.style.animationDelay = `${(index * 0.07).toFixed(2)}s`;
|
||||||
|
if (Number.isFinite(amount)) piece.style.setProperty("--fx", String(amount));
|
||||||
|
if (st.b) piece.style.fontWeight = "bold";
|
||||||
|
if (st.i) piece.style.fontStyle = "italic";
|
||||||
|
if (st.color) piece.style.color = st.color;
|
||||||
|
if (st.size) piece.style.fontSize = sizeValue(st.size, baseSize);
|
||||||
|
el.append(piece);
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!st.b && !st.i && !st.u && !st.s && !st.color && !st.size) {
|
if (!st.b && !st.i && !st.u && !st.s && !st.color && !st.size) {
|
||||||
el.append(shown);
|
el.append(shown);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -40,3 +40,16 @@ test("highlight wraps glossary terms case-insensitively and escapes regex charac
|
|||||||
assert.match(html, /<mark title="x">a\+b<\/mark>/);
|
assert.match(html, /<mark title="x">a\+b<\/mark>/);
|
||||||
assert.equal(highlight("plain", []), "plain");
|
assert.equal(highlight("plain", []), "plain");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("custom tags: letters of {bt} and {sc} move, a closing tag closes only its own tag, unknown pairs stay plain", async () => {
|
||||||
|
const { build } = await import("esbuild");
|
||||||
|
const { fileURLToPath } = await import("node:url");
|
||||||
|
const compiled = await build({ entryPoints: [fileURLToPath(new URL("../src/player/text.ts", import.meta.url))],
|
||||||
|
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
|
||||||
|
const { parseText, plainText } = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
|
||||||
|
const parsed = parseText("a{b}{bt=8}jump{/bt} still bold{/b} plain {chaos}odd{/chaos} end");
|
||||||
|
const styles = parsed.segments.filter((s) => s.kind === "text").map((s) => [s.text, !!s.style.b, s.style.fx ?? null]);
|
||||||
|
assert.deepEqual(styles, [["a", false, null], ["jump", true, "bt"], [" still bold", true, null], [" plain ", false, null],
|
||||||
|
["odd", false, null], [" end", false, null]]);
|
||||||
|
assert.equal(plainText(parsed), "ajump still bold plain odd end");
|
||||||
|
});
|
||||||
Reference in new issue
Block a user