diff --git a/web/packages/core/src/internal/player/inner.tsx b/web/packages/core/src/internal/player/inner.tsx index a5848340ac55..d5c842904ab5 100644 --- a/web/packages/core/src/internal/player/inner.tsx +++ b/web/packages/core/src/internal/player/inner.tsx @@ -2558,3 +2558,162 @@ function detectBrowserDirection(): string { return "ltr"; } + +type NamedAccessValue = HTMLElement | HTMLCollectionOf; + +/** + * + * @param v The value to check if is an HTMLCollectionOf + * + * @returns If the value is a collection + */ +function isCollection( + v: NamedAccessValue | undefined, +): v is HTMLCollectionOf { + return !!v && "item" in v && "length" in v; +} + +/** + * + * @param items The items to convert to an HTMLCollectionOf + * + * @returns An HTMLCollectionOf + */ +function makeCollection(items: HTMLElement[]): HTMLCollectionOf { + const base = Object.create( + HTMLCollection.prototype, + ) as HTMLCollectionOf; + + Object.defineProperty(base, "length", { + get() { + return items.length; + }, + enumerable: true, + configurable: true, + }); + + base.item = (index: number): HTMLElement | null => { + return items[index] ?? null; + }; + + base.namedItem = (name: string): HTMLElement | null => { + return items.find((el) => el.getAttribute("name") === name) ?? null; + }; + + (base as Iterable)[Symbol.iterator] = + function* (): Iterator { + for (const el of items) { + yield el; + } + }; + + const proxy = new Proxy(base, { + get(target, prop, receiver) { + if (typeof prop === "string") { + const index = Number(prop); + if (!Number.isNaN(index) && index >= 0) { + return items[index]; + } + } + return Reflect.get(target, prop, receiver); + }, + has(target, prop) { + if (typeof prop === "string") { + const index = Number(prop); + if (!Number.isNaN(index) && index >= 0) { + return index < items.length; + } + } + return Reflect.has(target, prop); + }, + }); + + return proxy as HTMLCollectionOf; +} + +const docMap = document as unknown as Record; + +/** + * Clear a property on document based on embed/object name + * + * @param el The embed/object element + * @param name The name for which to remove the property from document + */ +export function clearDocumentNamedAccessor( + el: HTMLElement, + name: string | undefined | null, +) { + if (!name) { + return; + } + const existing = docMap[name]; + + if ( + (existing instanceof HTMLCollection && + Array.from(existing).every((el) => + ["EMBED", "OBJECT"].includes(el.tagName), + )) || + (existing instanceof HTMLElement && + (existing.tagName === "EMBED" || existing.tagName === "OBJECT")) + ) { + return; + } + + // Simple case: single element + if (existing === el) { + Reflect.deleteProperty(document, name); + return; + } + if (isCollection(existing)) { + const items = [...existing].filter((x) => x !== el); + if (items.length === 0) { + Reflect.deleteProperty(document, name); + } else if (items.length === 1 && items[0]) { + docMap[name] = items[0]; + } else { + docMap[name] = makeCollection(items); + } + } +} + +/** + * Define a property on document based on embed/object name + * + * @param el The embed/object element + * @param name The name for which to define the property on document + */ +export function defineDocumentNamedAccessor( + el: HTMLElement, + name: string | null | undefined, +) { + if (!name) { + return; + } + + const existing = docMap[name]; + + if ( + (existing instanceof HTMLCollection && + Array.from(existing).every((el) => + ["EMBED", "OBJECT"].includes(el.tagName), + )) || + (existing instanceof HTMLElement && + (existing.tagName === "EMBED" || existing.tagName === "OBJECT")) + ) { + return; + } + + if (!existing) { + docMap[name] = el; + return; + } + + const items = isCollection(existing) + ? [...existing].filter((x) => x !== el) + : existing === el + ? [] + : [existing]; + + items.push(el); + docMap[name] = makeCollection(items); +} diff --git a/web/packages/core/src/internal/player/ruffle-embed-element.ts b/web/packages/core/src/internal/player/ruffle-embed-element.ts index f863ed043e01..08f8eee85a1f 100644 --- a/web/packages/core/src/internal/player/ruffle-embed-element.ts +++ b/web/packages/core/src/internal/player/ruffle-embed-element.ts @@ -4,6 +4,8 @@ import { isFallbackElement, isYoutubeFlashSource, workaroundYoutubeMixedContent, + clearDocumentNamedAccessor, + defineDocumentNamedAccessor, } from "./inner"; import { registerElement } from "../register-element"; import { isSwf } from "../../swf-utils"; @@ -17,6 +19,7 @@ import { isSwf } from "../../swf-utils"; * @internal */ export class RuffleEmbedElement extends RufflePlayerElement { + private _name: string = ""; /** * @ignore * @internal @@ -31,10 +34,23 @@ export class RuffleEmbedElement extends RufflePlayerElement { const options = getPolyfillOptions(src.value, getOptionString); // Kick off the SWF download. - this.load(options, true); + this.load(options, true).then(() => { + const name = this.attributes.getNamedItem("name")?.value; + defineDocumentNamedAccessor(this, name); + }); } } + /** + * @ignore + * @internal + */ + override disconnectedCallback(): void { + const name = this.attributes.getNamedItem("name")?.value; + clearDocumentNamedAccessor(this, name); + super.disconnectedCallback(); + } + /** * Polyfill of HTMLEmbedElement. * @@ -76,7 +92,7 @@ export class RuffleEmbedElement extends RufflePlayerElement { * @internal */ static override get observedAttributes(): string[] { - return [...RufflePlayerElement.observedAttributes, "src"]; + return [...RufflePlayerElement.observedAttributes, "src", "name"]; } /** @@ -89,6 +105,10 @@ export class RuffleEmbedElement extends RufflePlayerElement { newValue: string | undefined, ): void { super.attributeChangedCallback(name, oldValue, newValue); + if (name === "name" && oldValue !== newValue) { + this.name = newValue ?? ""; + return; + } if (this.isConnected && name === "src") { const src = this.attributes.getNamedItem("src"); if (src) { @@ -208,4 +228,30 @@ export class RuffleEmbedElement extends RufflePlayerElement { set type(typeVal: string) { this.setAttribute("type", typeVal); } + + /** + * Polyfill of name getter + * + * @ignore + * @internal + */ + get name(): string { + return this._name ?? ""; + } + + /** + * Polyfill of name setter + * + * @ignore + * @internal + */ + set name(name: string) { + const oldName = this._name; + this._name = name; + if (oldName !== name) { + clearDocumentNamedAccessor(this, oldName); + defineDocumentNamedAccessor(this, name); + } + this.setAttribute("name", name); + } } diff --git a/web/packages/core/src/internal/player/ruffle-object-element.ts b/web/packages/core/src/internal/player/ruffle-object-element.ts index 1caf0588d485..72665b036596 100644 --- a/web/packages/core/src/internal/player/ruffle-object-element.ts +++ b/web/packages/core/src/internal/player/ruffle-object-element.ts @@ -4,6 +4,8 @@ import { isFallbackElement, isYoutubeFlashSource, workaroundYoutubeMixedContent, + clearDocumentNamedAccessor, + defineDocumentNamedAccessor, } from "./inner"; import { FLASH_ACTIVEX_CLASSID } from "../../flash-identifiers"; import { registerElement } from "../register-element"; @@ -65,6 +67,7 @@ function paramsOf(elem: Element): Record { */ export class RuffleObjectElement extends RufflePlayerElement { private params: Record = {}; + private _name: string = ""; /** * @ignore @@ -101,10 +104,23 @@ export class RuffleObjectElement extends RufflePlayerElement { const options = getPolyfillOptions(url, getOptionString); // Kick off the SWF download. - this.load(options, true); + this.load(options, true).then(() => { + const name = this.attributes.getNamedItem("name")?.value; + defineDocumentNamedAccessor(this, name); + }); } } + /** + * @ignore + * @internal + */ + override disconnectedCallback(): void { + const name = this.attributes.getNamedItem("name")?.value; + clearDocumentNamedAccessor(this, name); + super.disconnectedCallback(); + } + protected override debugPlayerInfo(): string { let result = "Player type: Object\n"; @@ -344,4 +360,54 @@ export class RuffleObjectElement extends RufflePlayerElement { set type(typeVal: string) { this.setAttribute("type", typeVal); } + + /** + * @ignore + * @internal + */ + static override get observedAttributes(): string[] { + return [...RufflePlayerElement.observedAttributes, "name"]; + } + + /** + * @ignore + * @internal + */ + override attributeChangedCallback( + name: string, + oldValue: string | undefined, + newValue: string | undefined, + ): void { + super.attributeChangedCallback(name, oldValue, newValue); + if (name === "name" && oldValue !== newValue) { + this.name = newValue ?? ""; + return; + } + } + + /** + * Polyfill of name getter + * + * @ignore + * @internal + */ + get name(): string { + return this._name ?? ""; + } + + /** + * Polyfill of name setter + * + * @ignore + * @internal + */ + set name(name: string) { + const oldName = this._name; + this._name = name; + if (oldName !== name) { + clearDocumentNamedAccessor(this, oldName); + defineDocumentNamedAccessor(this, name); + } + this.setAttribute("name", name); + } } diff --git a/web/packages/selfhosted/test/polyfill/document_accessors/index.html b/web/packages/selfhosted/test/polyfill/document_accessors/index.html new file mode 100644 index 000000000000..d80d776f668c --- /dev/null +++ b/web/packages/selfhosted/test/polyfill/document_accessors/index.html @@ -0,0 +1,17 @@ + + + + + DOCUMENT ACCESSORS + + + +
+ + + + +
+
    + + diff --git a/web/packages/selfhosted/test/polyfill/document_accessors/test.ts b/web/packages/selfhosted/test/polyfill/document_accessors/test.ts new file mode 100644 index 000000000000..1ad265245c37 --- /dev/null +++ b/web/packages/selfhosted/test/polyfill/document_accessors/test.ts @@ -0,0 +1,176 @@ +import { injectRuffleAndWait, openTest, playAndMonitor } from "../../utils.js"; +import { expect } from "chai"; + +describe("Document accessor", () => { + it("loads the test", async () => { + await openTest(browser, `polyfill/document_accessors`); + }); + + it("Accesses the right content with ruffle", async () => { + async function setNameAttr(selector: string, value: string) { + const el = await $(selector); + await browser.execute( + (element, val) => { + element.setAttribute("name", val); + }, + el, + value, + ); + } + + async function setName(selector: string, value: string) { + const el = await $(selector); + await browser.execute( + (element, val) => { + (element as HTMLEmbedElement | HTMLObjectElement).name = + val; + }, + el, + value, + ); + } + + async function removeEl(selector: string) { + const el = await $(selector); + await browser.execute((element) => { + element.remove(); + }, el); + } + + async function getAccessorInfo(name: string) { + return await browser.execute((name) => { + const value = ( + document as unknown as Record< + string, + HTMLElement | HTMLCollectionOf + > + )[name]; + const result = { embeds: 0, objects: 0, length: 0, type: "" }; + + if (!value) { + return result; + } + + if (value instanceof HTMLElement) { + result.length = 1; + result.type = value.nodeName; + if (value.nodeName === "EMBED") { + result.embeds = 1; + } + if (value.nodeName === "OBJECT") { + result.objects = 1; + } + return result; + } + + if ("length" in value) { + result.type = "HTMLCollectionLike"; + result.length = value.length; + for (let i = 0; i < value.length; i++) { + const node = value.item(i); + if (node?.nodeName === "EMBED") { + result.embeds++; + } + if (node?.nodeName === "OBJECT") { + result.objects++; + } + } + } + + return result; + }, name); + } + + await injectRuffleAndWait(browser); + await playAndMonitor( + browser, + await browser.$("#test-container").$("ruffle-embed#emb1"), + ); + await playAndMonitor( + browser, + await browser.$("#test-container").$("ruffle-embed#emb2"), + ); + await playAndMonitor( + browser, + await browser.$("#test-container").$("ruffle-embed#emb3"), + ); + await playAndMonitor( + browser, + await browser.$("#test-container").$("ruffle-object#obj1"), + ); + + // + // Before changes + // + const fl = await getAccessorInfo("fl"); + expect(fl.type).to.equal("HTMLCollectionLike"); + expect(fl.length).to.equal(4); + expect(fl.embeds).to.equal(3); + expect(fl.objects).to.equal(1); + + // + // Change emb1.name = "test" + // + await setName("#emb1", "test"); + const test1 = await getAccessorInfo("test"); + const fl1 = await getAccessorInfo("fl"); + + expect(test1.type).to.equal("EMBED"); + expect(test1.length).to.equal(1); + expect(test1.embeds).to.equal(1); + expect(test1.objects).to.equal(0); + + expect(fl1.type).to.equal("HTMLCollectionLike"); + expect(fl1.length).to.equal(3); + expect(fl1.embeds).to.equal(2); + expect(fl1.objects).to.equal(1); + + // + // Change emb2.setAttribute("name", "test") + // + await setNameAttr("#emb2", "test"); + const test2 = await getAccessorInfo("test"); + const fl2 = await getAccessorInfo("fl"); + + expect(test2.type).to.equal("HTMLCollectionLike"); + expect(test2.length).to.equal(2); + expect(test2.embeds).to.equal(2); + expect(test2.objects).to.equal(0); + + expect(fl2.type).to.equal("HTMLCollectionLike"); + expect(fl2.length).to.equal(2); + expect(fl2.embeds).to.equal(1); + expect(fl2.objects).to.equal(1); + + // + // Move emb2 back to name="fl" + // + await setName("#emb2", "fl"); + const test3 = await getAccessorInfo("test"); + const fl3 = await getAccessorInfo("fl"); + + expect(test3.type).to.equal("EMBED"); + expect(test3.length).to.equal(1); + expect(test3.embeds).to.equal(1); + expect(test3.objects).to.equal(0); + + expect(fl3.type).to.equal("HTMLCollectionLike"); + expect(fl3.length).to.equal(3); + expect(fl3.embeds).to.equal(2); + expect(fl3.objects).to.equal(1); + + // + // Remove emb1 + // + await removeEl("#emb1"); + const test4 = await getAccessorInfo("test"); + const fl4 = await getAccessorInfo("fl"); + + expect(test4.type).to.equal(""); + expect(test4.length).to.equal(0); + expect(fl4.type).to.equal("HTMLCollectionLike"); + expect(fl4.length).to.equal(3); + expect(fl4.embeds).to.equal(2); + expect(fl4.objects).to.equal(1); + }); +});