mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 19:06:04 +00:00
fix(studio): harden composition timeline reliability (#2615)
* fix(studio): preserve composition playback continuity * feat(studio): drag compositions into the timeline * fix(studio): collapse expanded composition move aliases * fix(studio): make timeline cuts atomic * fix(studio): group inspector gesture history * test(studio): cover masked text selection * fix(studio): harden composition timeline reliability * fix(studio): satisfy CI source gates * fix(studio): harden composition mutation requests
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { CompositionInsertionError, insertCompositionIntoSource } from "./compositionInsertion";
|
||||
|
||||
const dirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function project(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-comp-insert-"));
|
||||
dirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
const parent = `<!doctype html><html><body><div data-composition-id="main" data-width="1920" data-height="1080" data-duration="6"><div id="occupied" data-start="1" data-duration="5" data-track-index="2"></div></div></body></html>`;
|
||||
const child = `<template><div data-composition-id="headline" data-width="1920" data-height="1080" data-duration="4.9"><h1>Title</h1></div></template>`;
|
||||
|
||||
function writeFixture(dir: string): void {
|
||||
writeFileSync(join(dir, "index.html"), parent);
|
||||
writeFileSync(join(dir, "headline.html"), child);
|
||||
}
|
||||
|
||||
describe("insertCompositionIntoSource", () => {
|
||||
it("inserts template-root compositions with stable timing and spills a collision", () => {
|
||||
const dir = project();
|
||||
writeFixture(dir);
|
||||
|
||||
const result = insertCompositionIntoSource({
|
||||
projectDir: dir,
|
||||
targetPath: "index.html",
|
||||
sourcePath: "headline.html",
|
||||
parentSource: parent,
|
||||
start: 2,
|
||||
desiredTrack: 2,
|
||||
});
|
||||
const host = parseHTML(result.html).document.getElementById(result.hostId);
|
||||
|
||||
expect(result.track).toBe(3);
|
||||
expect(host?.getAttribute("data-composition-src")).toBe("headline.html");
|
||||
expect(host?.getAttribute("data-playback-start")).toBe("0");
|
||||
expect(host?.getAttribute("data-duration")).toBe("4.9");
|
||||
expect(host?.getAttribute("data-hf-id")).toMatch(/^hf-/);
|
||||
expect(result.html).toContain('data-duration="6.9"');
|
||||
});
|
||||
|
||||
it("gives repeated sources distinct host identities", () => {
|
||||
const dir = project();
|
||||
writeFixture(dir);
|
||||
const first = insertCompositionIntoSource({
|
||||
projectDir: dir,
|
||||
targetPath: "index.html",
|
||||
sourcePath: "headline.html",
|
||||
parentSource: parent,
|
||||
start: 0,
|
||||
desiredTrack: 0,
|
||||
});
|
||||
writeFileSync(join(dir, "index.html"), first.html);
|
||||
const second = insertCompositionIntoSource({
|
||||
projectDir: dir,
|
||||
targetPath: "index.html",
|
||||
sourcePath: "headline.html",
|
||||
parentSource: first.html,
|
||||
start: 5,
|
||||
desiredTrack: 0,
|
||||
});
|
||||
|
||||
expect(second.hostId).not.toBe(first.hostId);
|
||||
expect(second.html.match(/data-composition-src="headline.html"/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("reserves existing composition identities even when host ids are missing", () => {
|
||||
const dir = project();
|
||||
const existingParent = `<!doctype html><html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="6">
|
||||
<div data-composition-id="headline" data-composition-src="headline.html" data-start="0" data-duration="1" data-track-index="0"></div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
writeFileSync(join(dir, "index.html"), existingParent);
|
||||
writeFileSync(join(dir, "headline.html"), child);
|
||||
|
||||
const result = insertCompositionIntoSource({
|
||||
projectDir: dir,
|
||||
targetPath: "index.html",
|
||||
sourcePath: "headline.html",
|
||||
parentSource: existingParent,
|
||||
start: 2,
|
||||
desiredTrack: 0,
|
||||
});
|
||||
const document = parseHTML(result.html).document;
|
||||
const ids = Array.from(document.querySelectorAll("[data-composition-id]")).map((element) =>
|
||||
element.getAttribute("data-composition-id"),
|
||||
);
|
||||
|
||||
expect(result.hostId).toBe("headline_2");
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("ignores nested composition internals when resolving a parent track collision", () => {
|
||||
const dir = project();
|
||||
const inlineParent = `<!doctype html><html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="6">
|
||||
<div data-composition-id="nested" data-composition-src="nested.html" data-start="0" data-duration="6" data-track-index="0">
|
||||
<div data-start="2" data-duration="3" data-track-index="2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
writeFileSync(join(dir, "index.html"), inlineParent);
|
||||
writeFileSync(join(dir, "headline.html"), child);
|
||||
|
||||
const result = insertCompositionIntoSource({
|
||||
projectDir: dir,
|
||||
targetPath: "index.html",
|
||||
sourcePath: "headline.html",
|
||||
parentSource: inlineParent,
|
||||
start: 2,
|
||||
desiredTrack: 2,
|
||||
});
|
||||
|
||||
expect(result.track).toBe(2);
|
||||
});
|
||||
|
||||
it("inserts into a template-root parent composition", () => {
|
||||
const dir = project();
|
||||
const templateParent = `<template><div data-composition-id="parent" data-width="1920" data-height="1080" data-duration="2"></div></template>`;
|
||||
writeFileSync(join(dir, "parent.html"), templateParent);
|
||||
writeFileSync(join(dir, "headline.html"), child);
|
||||
|
||||
const result = insertCompositionIntoSource({
|
||||
projectDir: dir,
|
||||
targetPath: "parent.html",
|
||||
sourcePath: "headline.html",
|
||||
parentSource: templateParent,
|
||||
start: 1,
|
||||
desiredTrack: 0,
|
||||
});
|
||||
|
||||
expect(result.html).toContain('data-composition-src="headline.html"');
|
||||
expect(result.html).toContain('data-duration="5.9"');
|
||||
});
|
||||
|
||||
it("rejects self-nesting, transitive cycles, missing files, and invalid durations", () => {
|
||||
const dir = project();
|
||||
writeFixture(dir);
|
||||
writeFileSync(
|
||||
join(dir, "middle.html"),
|
||||
`<div data-composition-id="middle" data-width="1" data-height="1" data-duration="1"><div data-composition-src="index.html"></div></div>`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, "cycle-source.html"),
|
||||
`<div data-composition-id="cycle" data-width="1" data-height="1" data-duration="1"><div data-composition-src="middle.html"></div></div>`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, "invalid.html"),
|
||||
`<div data-composition-id="invalid" data-width="1" data-height="1" data-duration="0"></div>`,
|
||||
);
|
||||
const insert = (sourcePath: string) =>
|
||||
insertCompositionIntoSource({
|
||||
projectDir: dir,
|
||||
targetPath: "index.html",
|
||||
sourcePath,
|
||||
parentSource: parent,
|
||||
start: 0,
|
||||
desiredTrack: 0,
|
||||
});
|
||||
|
||||
expect(() => insert("index.html")).toThrow(/cycle/);
|
||||
expect(() => insert("cycle-source.html")).toThrow(/cycle/);
|
||||
expect(() => insert("missing.html")).toThrow(CompositionInsertionError);
|
||||
expect(() => insert("invalid.html")).toThrow(/valid data-composition-duration/);
|
||||
expect(() => insert("../outside.html")).toThrow(CompositionInsertionError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { dirname, relative, resolve, sep } from "node:path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { isSafePath, resolveWithinProject } from "./safePath.js";
|
||||
|
||||
export class CompositionInsertionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: 400 | 404,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function descendants(root: Document | Element, selector: string): Element[] {
|
||||
const found = Array.from(root.querySelectorAll(selector));
|
||||
for (const template of root.querySelectorAll("template")) {
|
||||
found.push(...descendants(template, selector));
|
||||
}
|
||||
return [...new Set(found)];
|
||||
}
|
||||
|
||||
function compositionRoot(source: string): { document: Document; root: Element } {
|
||||
const document = parseHTML(source).document;
|
||||
const root = descendants(document, "[data-composition-id]")[0];
|
||||
if (!root) throw new CompositionInsertionError("Composition source has no root", 400);
|
||||
return { document, root };
|
||||
}
|
||||
|
||||
function positiveAttribute(root: Element, ...names: string[]): number {
|
||||
for (const name of names) {
|
||||
const value = Number.parseFloat(root.getAttribute(name) ?? "");
|
||||
if (Number.isFinite(value) && value > 0) return value;
|
||||
}
|
||||
throw new CompositionInsertionError(`Composition source has no valid ${names[0]}`, 400);
|
||||
}
|
||||
|
||||
function canonicalProjectPath(projectDir: string, candidate: string | null): string {
|
||||
if (!candidate) {
|
||||
throw new CompositionInsertionError("Composition source escapes the project", 400);
|
||||
}
|
||||
if (!existsSync(candidate)) {
|
||||
throw new CompositionInsertionError("Composition source was not found", 404);
|
||||
}
|
||||
const canonical = realpathSync(candidate);
|
||||
if (!isSafePath(realpathSync(projectDir), canonical)) {
|
||||
throw new CompositionInsertionError("Composition source escapes the project", 400);
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
function validateSourcePath(sourcePath: string): void {
|
||||
if (!sourcePath.trim() || sourcePath.includes("\0") || /^[a-z]+:/i.test(sourcePath)) {
|
||||
throw new CompositionInsertionError("Invalid composition source path", 400);
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalProjectFile(projectDir: string, sourcePath: string): string {
|
||||
validateSourcePath(sourcePath);
|
||||
return canonicalProjectPath(projectDir, resolveWithinProject(projectDir, sourcePath));
|
||||
}
|
||||
|
||||
function canonicalDependency(projectDir: string, ownerAbs: string, sourcePath: string): string {
|
||||
validateSourcePath(sourcePath);
|
||||
return canonicalProjectPath(
|
||||
projectDir,
|
||||
resolveWithinProject(projectDir, relative(projectDir, resolve(dirname(ownerAbs), sourcePath))),
|
||||
);
|
||||
}
|
||||
|
||||
function validateDependencyGraph(projectDir: string, targetAbs: string, sourceAbs: string): void {
|
||||
const visited = new Set<string>();
|
||||
const visiting = new Set<string>();
|
||||
const visit = (file: string) => {
|
||||
if (file === targetAbs) {
|
||||
throw new CompositionInsertionError("Composition insertion would create a cycle", 400);
|
||||
}
|
||||
if (visiting.has(file)) {
|
||||
throw new CompositionInsertionError("Composition dependency cycle detected", 400);
|
||||
}
|
||||
if (visited.has(file)) return;
|
||||
visiting.add(file);
|
||||
const source = readFileSync(file, "utf-8");
|
||||
const { document } = compositionRoot(source);
|
||||
for (const host of descendants(document, "[data-composition-src]")) {
|
||||
const dependency = host.getAttribute("data-composition-src");
|
||||
if (dependency) {
|
||||
visit(canonicalDependency(projectDir, file, dependency));
|
||||
}
|
||||
}
|
||||
visiting.delete(file);
|
||||
visited.add(file);
|
||||
};
|
||||
visit(sourceAbs);
|
||||
}
|
||||
|
||||
function numberAttribute(element: Element, name: string, fallback = 0): number {
|
||||
const value = Number.parseFloat(element.getAttribute(name) ?? "");
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function rangesOverlap(start: number, duration: number, other: Element): boolean {
|
||||
const otherStart = numberAttribute(other, "data-start");
|
||||
const otherDuration = numberAttribute(other, "data-duration");
|
||||
return start < otherStart + otherDuration && otherStart < start + duration;
|
||||
}
|
||||
|
||||
function resolveTrack(
|
||||
root: Element,
|
||||
desiredTrack: number,
|
||||
start: number,
|
||||
duration: number,
|
||||
): number {
|
||||
const clips = descendants(root, "[data-start][data-duration]").filter(
|
||||
(element) =>
|
||||
element !== root && element.parentElement?.closest("[data-composition-id]") === root,
|
||||
);
|
||||
const tracks = [...new Set(clips.map((clip) => numberAttribute(clip, "data-track-index")))].sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
const isFree = (track: number) =>
|
||||
!clips.some(
|
||||
(clip) =>
|
||||
numberAttribute(clip, "data-track-index") === track && rangesOverlap(start, duration, clip),
|
||||
);
|
||||
if (isFree(desiredTrack)) return desiredTrack;
|
||||
const row = tracks.indexOf(desiredTrack);
|
||||
for (let index = row - 1; index >= 0; index--) {
|
||||
const track = tracks[index];
|
||||
if (track !== undefined && isFree(track)) return track;
|
||||
}
|
||||
for (let index = Math.max(0, row + 1); index < tracks.length; index++) {
|
||||
const track = tracks[index];
|
||||
if (track !== undefined && isFree(track)) return track;
|
||||
}
|
||||
return Math.max(desiredTrack, ...tracks, -1) + 1;
|
||||
}
|
||||
|
||||
function uniqueHostId(root: Element, base: string): string {
|
||||
const ids = new Set([
|
||||
...descendants(root, "[id]").map((element) => element.id),
|
||||
...descendants(root, "[data-composition-id]").flatMap((element) => {
|
||||
const id = element.getAttribute("data-composition-id");
|
||||
return id ? [id] : [];
|
||||
}),
|
||||
]);
|
||||
if (!ids.has(base)) return base;
|
||||
let suffix = 2;
|
||||
while (ids.has(`${base}_${suffix}`)) suffix += 1;
|
||||
return `${base}_${suffix}`;
|
||||
}
|
||||
|
||||
function relativeSourcePath(targetAbs: string, sourceAbs: string): string {
|
||||
return relative(dirname(targetAbs), sourceAbs).split(sep).join("/");
|
||||
}
|
||||
|
||||
export function insertCompositionIntoSource(input: {
|
||||
projectDir: string;
|
||||
targetPath: string;
|
||||
sourcePath: string;
|
||||
parentSource: string;
|
||||
start: number;
|
||||
desiredTrack: number;
|
||||
}): { html: string; hostId: string; track: number; duration: number } {
|
||||
const targetAbs = canonicalProjectFile(input.projectDir, input.targetPath);
|
||||
const sourceAbs = canonicalProjectFile(input.projectDir, input.sourcePath);
|
||||
validateDependencyGraph(input.projectDir, targetAbs, sourceAbs);
|
||||
|
||||
const source = readFileSync(sourceAbs, "utf-8");
|
||||
const sourceComposition = compositionRoot(source).root;
|
||||
const duration = positiveAttribute(
|
||||
sourceComposition,
|
||||
"data-composition-duration",
|
||||
"data-duration",
|
||||
);
|
||||
const width = positiveAttribute(sourceComposition, "data-width");
|
||||
const height = positiveAttribute(sourceComposition, "data-height");
|
||||
const { document, root } = compositionRoot(input.parentSource);
|
||||
const parentDuration = positiveAttribute(root, "data-duration", "data-composition-duration");
|
||||
const base =
|
||||
(sourceComposition.getAttribute("data-composition-id") ?? "composition")
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "composition";
|
||||
const hostId = uniqueHostId(root, base);
|
||||
const track = resolveTrack(
|
||||
root,
|
||||
Math.max(0, Math.round(input.desiredTrack)),
|
||||
input.start,
|
||||
duration,
|
||||
);
|
||||
const zIndex =
|
||||
Math.max(
|
||||
0,
|
||||
...descendants(root, "[style]").map((element) => {
|
||||
const match = /(?:^|;)\s*z-index\s*:\s*(-?\d+)/i.exec(element.getAttribute("style") ?? "");
|
||||
return match?.[1] ? Number.parseInt(match[1], 10) : 0;
|
||||
}),
|
||||
) + 1;
|
||||
|
||||
const host = document.createElement("div");
|
||||
host.id = hostId;
|
||||
host.className = "clip";
|
||||
host.setAttribute("data-hf-id", `hf-${randomUUID()}`);
|
||||
host.setAttribute("data-composition-id", hostId);
|
||||
host.setAttribute("data-composition-src", relativeSourcePath(targetAbs, sourceAbs));
|
||||
host.setAttribute("data-start", String(Math.round(input.start * 100) / 100));
|
||||
host.setAttribute("data-duration", String(duration));
|
||||
host.setAttribute("data-playback-start", "0");
|
||||
host.setAttribute("data-track-index", String(track));
|
||||
host.setAttribute("data-width", String(width));
|
||||
host.setAttribute("data-height", String(height));
|
||||
host.setAttribute(
|
||||
"style",
|
||||
`position: absolute; left: 0px; top: 0px; width: ${width}px; height: ${height}px; z-index: ${zIndex}`,
|
||||
);
|
||||
root.appendChild(host);
|
||||
if (input.start + duration > parentDuration) {
|
||||
const name = root.hasAttribute("data-duration") ? "data-duration" : "data-composition-duration";
|
||||
root.setAttribute(name, String(Math.round((input.start + duration) * 100) / 100));
|
||||
}
|
||||
return { html: document.toString(), hostId, track, duration };
|
||||
}
|
||||
@@ -258,7 +258,13 @@ export function splitElementInHtml(
|
||||
target: SourceMutationTarget,
|
||||
splitTime: number,
|
||||
newId: string,
|
||||
fallbackTiming?: { start: number; duration: number },
|
||||
fallbackTiming?: {
|
||||
start: number;
|
||||
duration: number;
|
||||
playbackStart?: number;
|
||||
playbackRate?: number;
|
||||
stampPlaybackStart?: boolean;
|
||||
},
|
||||
): SplitElementResult {
|
||||
const { document, wrappedFragment } = parseSourceDocument(source);
|
||||
const el = findTargetElement(document, target);
|
||||
@@ -292,6 +298,19 @@ export function splitElementInHtml(
|
||||
const clone = el.cloneNode(true);
|
||||
if (!isHTMLElement(clone)) return { html: source, matched: false, newId: null };
|
||||
clone.setAttribute("id", newId);
|
||||
const compositionId = clone.getAttribute("data-composition-id");
|
||||
if (compositionId) {
|
||||
const usedCompositionIds = new Set(
|
||||
Array.from(document.querySelectorAll("[data-composition-id]"), (node) =>
|
||||
node.getAttribute("data-composition-id"),
|
||||
),
|
||||
);
|
||||
const base = `${compositionId}-split`;
|
||||
let nextCompositionId = base;
|
||||
let suffix = 2;
|
||||
while (usedCompositionIds.has(nextCompositionId)) nextCompositionId = `${base}-${suffix++}`;
|
||||
clone.setAttribute("data-composition-id", nextCompositionId);
|
||||
}
|
||||
clone.removeAttribute("data-hf-id");
|
||||
// Descendants carry their own data-hf-id; leaving them duplicates the id of
|
||||
// every nested node (e.g. an inner <span>), so strip them on the clone too.
|
||||
@@ -306,11 +325,16 @@ export function splitElementInHtml(
|
||||
? "data-playback-start"
|
||||
: el.hasAttribute("data-media-start")
|
||||
? "data-media-start"
|
||||
: null;
|
||||
: fallbackTiming?.stampPlaybackStart
|
||||
? "data-playback-start"
|
||||
: null;
|
||||
if (playbackStartAttr) {
|
||||
const currentTrim = parseFloat(el.getAttribute(playbackStartAttr) ?? "0") || 0;
|
||||
const currentTrim =
|
||||
parseFloat(el.getAttribute(playbackStartAttr) ?? "") || fallbackTiming?.playbackStart || 0;
|
||||
const rateRaw = parseFloat(el.getAttribute("data-playback-rate") ?? "");
|
||||
const rate = Number.isFinite(rateRaw) ? rateRaw : 1;
|
||||
const rate =
|
||||
Number.isFinite(rateRaw) && rateRaw > 0 ? rateRaw : (fallbackTiming?.playbackRate ?? 1);
|
||||
el.setAttribute(playbackStartAttr, String(Math.round(currentTrim * 1000) / 1000));
|
||||
clone.setAttribute(
|
||||
playbackStartAttr,
|
||||
String(Math.round((currentTrim + firstDuration * rate) * 1000) / 1000),
|
||||
|
||||
@@ -70,6 +70,25 @@ describe("splitElementInHtml", () => {
|
||||
expect(result.html).toMatch(/id="box-split"[^>]*class="clip"/);
|
||||
});
|
||||
|
||||
it("gives a split composition host a unique composition id", () => {
|
||||
const composition = source.replace(
|
||||
'id="box" class="clip"',
|
||||
'id="box" class="clip" data-composition-id="headline" data-composition-src="headline.html"',
|
||||
);
|
||||
|
||||
const first = splitElementInHtml(composition, { id: "box" }, 3, "box-split");
|
||||
const second = splitElementInHtml(first.html, { id: "box-split" }, 4, "box-split-2");
|
||||
|
||||
const { document } = parseHTML(second.html);
|
||||
const compositionIds = Array.from(
|
||||
document.querySelectorAll("[data-composition-id]"),
|
||||
(element) => element.getAttribute("data-composition-id"),
|
||||
);
|
||||
expect(compositionIds).toHaveLength(4);
|
||||
expect(new Set(compositionIds)).toHaveLength(4);
|
||||
expect(compositionIds).toEqual(expect.arrayContaining(["root", "headline", "headline-split"]));
|
||||
});
|
||||
|
||||
it("returns matched false for out-of-range split time", () => {
|
||||
expect(splitElementInHtml(source, { id: "box" }, 0.5, "box-split").matched).toBe(false);
|
||||
expect(splitElementInHtml(source, { id: "box" }, 7.5, "box-split").matched).toBe(false);
|
||||
@@ -103,6 +122,20 @@ describe("splitElementInHtml", () => {
|
||||
const result = splitElementInHtml(mediaSource, { id: "box" }, 3, "box-split");
|
||||
expect(result.html).toMatch(/id="box-split"[^>]*data-playback-start="2"/);
|
||||
});
|
||||
|
||||
it("stamps a legacy composition offset and advances the second half by playback rate", () => {
|
||||
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split", {
|
||||
start: 1,
|
||||
duration: 6,
|
||||
playbackStart: 1.5,
|
||||
playbackRate: 2,
|
||||
stampPlaybackStart: true,
|
||||
});
|
||||
|
||||
const { document } = parseHTML(result.html);
|
||||
expect(document.getElementById("box")?.getAttribute("data-playback-start")).toBe("1.5");
|
||||
expect(document.getElementById("box-split")?.getAttribute("data-playback-start")).toBe("5.5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapElementsInHtml / unwrapElementsFromHtml", () => {
|
||||
|
||||
Reference in New Issue
Block a user