mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
fix(street-level): KartaView round-2 follow-ups — context loss, size cap, slow lookups
From the second IC8 pass (launch ready; P3s closed here): - A lost WebGL context is redrawn on a new canvas (the old one only gives back its lost context), through the core, and only if no newer photo is opening; verified live with WEBGL_lose_context, twice. - The 16 MB answer cap counts bytes while reading: KartaView sends chunked answers with no Content-Length, so the header check never fired. - A lookup gets 8 s, then 15 s once more: KartaView sometimes sits on a lookup for 11 s and answers the same one in 3 s. Left as follow-ups: cancelling abandoned sequence pages; finding a page by binary search on the index when thousands of photos are missing. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
0394a9b908
commit
97a1a667de
7 files changed
+158
-24
No files matched your search
@@ -3221,7 +3221,10 @@ up front at the ends. `api.kartaview.org` speaks HTTP/1.1, so of the
|
|||||||
browser's six connections to it tiles take three, lookups two and pages one
|
browser's six connections to it tiles take three, lookups two and pages one
|
||||||
(newest first), and a lookup never waits behind tiles; the photo API answers
|
(newest first), and a lookup never waits behind tiles; the photo API answers
|
||||||
429 to more than about three requests at once, so a 429 is retried with
|
429 to more than about three requests at once, so a 429 is retried with
|
||||||
backoff (at most 10 s) and a timed-out lookup once. Photo URLs are built from
|
backoff (at most 10 s), and a lookup gets 8 s, then 15 s once more (KartaView
|
||||||
|
sometimes sits on a lookup for 11 s and answers the same one in 3 s). Answers
|
||||||
|
are read with a running byte count and cut off past 16 MB (KartaView sends
|
||||||
|
them chunked, without a length). Photo URLs are built from
|
||||||
`storage` and `filepath*`: the API's `fileurl*` always name storage1, where
|
`storage` and `filepath*`: the API's `fileurl*` always name storage1, where
|
||||||
photos stored elsewhere 404, and a `fileurl*` fallback must itself be an
|
photos stored elsewhere 404, and a `fileurl*` fallback must itself be an
|
||||||
https storage host. The viewer (`viewer.js`) shows a flat photo at 1,280 px,
|
https storage host. The viewer (`viewer.js`) shows a flat photo at 1,280 px,
|
||||||
@@ -3232,7 +3235,8 @@ equirectangular `wrapped_proc` image (13,000 px wide upstream) resized to
|
|||||||
centre = the camera heading; drag, pinch, wheel, Shift+← →, ↑ ↓, + −; the
|
centre = the camera heading; drag, pinch, wheel, Shift+← →, ↑ ↓, + −; the
|
||||||
canvas re-measures every frame). Without WebGL, or if the proxy fails within
|
canvas re-measures every frame). Without WebGL, or if the proxy fails within
|
||||||
15 s, a 360° photo shows its front view; a lost context draws the photo again
|
15 s, a 360° photo shows its front view; a lost context draws the photo again
|
||||||
on a new one. FIT / FILL hide for panoramas (`supportsRenderModes`). The
|
on a new canvas (a lost canvas only gives back its lost context), through the
|
||||||
|
core and only if no newer photo is opening. FIT / FILL hide for panoramas (`supportsRenderModes`). The
|
||||||
caption has no place name (KartaView's is where the drive began) and links to
|
caption has no place name (KartaView's is where the drive began) and links to
|
||||||
the full-size photo through the image proxy, since kartaview.org's photo
|
the full-size photo through the image proxy, since kartaview.org's photo
|
||||||
pages fail for signed-out visitors.
|
pages fail for signed-out visitors.
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ export const TILE_CONCURRENCY = 3;
|
|||||||
export const PAGE_CONCURRENCY = 1;
|
export const PAGE_CONCURRENCY = 1;
|
||||||
export const TILE_TIMEOUT_MS = 20_000;
|
export const TILE_TIMEOUT_MS = 20_000;
|
||||||
export const PHOTO_API_TIMEOUT_MS = 15_000;
|
export const PHOTO_API_TIMEOUT_MS = 15_000;
|
||||||
|
/**
|
||||||
|
* The first try of a lookup: KartaView sometimes sits on a lookup for 11 s
|
||||||
|
* and more, then answers the same one again in 3 s.
|
||||||
|
*/
|
||||||
|
export const PHOTO_API_FIRST_TIMEOUT_MS = 8_000;
|
||||||
/** A sequence page is about 650 kB of JSON. */
|
/** A sequence page is about 650 kB of JSON. */
|
||||||
export const PAGE_TIMEOUT_MS = 30_000;
|
export const PAGE_TIMEOUT_MS = 30_000;
|
||||||
/** Real tiles are under 1 MB; refuse anything absurd before parsing it. */
|
/** Real tiles are under 1 MB; refuse anything absurd before parsing it. */
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
PHOTO_API_RETRIES,
|
PHOTO_API_RETRIES,
|
||||||
PHOTO_API_RETRY_BASE_MS,
|
PHOTO_API_RETRY_BASE_MS,
|
||||||
PHOTO_API_RETRY_MAX_MS,
|
PHOTO_API_RETRY_MAX_MS,
|
||||||
|
PHOTO_API_FIRST_TIMEOUT_MS,
|
||||||
PHOTO_API_TIMEOUT_MS,
|
PHOTO_API_TIMEOUT_MS,
|
||||||
TILE_CONCURRENCY,
|
TILE_CONCURRENCY,
|
||||||
TILE_TIMEOUT_MS,
|
TILE_TIMEOUT_MS,
|
||||||
@@ -144,11 +145,33 @@ export function createKartaViewSource({
|
|||||||
{ status: response.status },
|
{ status: response.status },
|
||||||
);
|
);
|
||||||
const length = Number(response.headers?.get?.('content-length'));
|
const length = Number(response.headers?.get?.('content-length'));
|
||||||
if (length > MAX_RESPONSE_BYTES)
|
const tooMuch = () =>
|
||||||
throw new KartaViewSourceError('KartaView sent too much data', {
|
new KartaViewSourceError('KartaView sent too much data', {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
});
|
});
|
||||||
return await response.json();
|
if (length > MAX_RESPONSE_BYTES) throw tooMuch();
|
||||||
|
// KartaView answers chunked, without a length: count while reading.
|
||||||
|
const reader = response.body?.getReader?.();
|
||||||
|
if (!reader) return await response.json();
|
||||||
|
const chunks = [];
|
||||||
|
let bytes = 0;
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
bytes += value.byteLength;
|
||||||
|
if (bytes > MAX_RESPONSE_BYTES) {
|
||||||
|
reader.cancel().catch(() => {});
|
||||||
|
throw tooMuch();
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
const body = new Uint8Array(bytes);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
body.set(chunk, offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
return JSON.parse(new TextDecoder().decode(body));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (signal?.aborted) throw signal.reason;
|
if (signal?.aborted) throw signal.reason;
|
||||||
if (controller.signal.aborted) throw controller.signal.reason;
|
if (controller.signal.aborted) throw controller.signal.reason;
|
||||||
@@ -168,7 +191,11 @@ export function createKartaViewSource({
|
|||||||
path,
|
path,
|
||||||
params,
|
params,
|
||||||
{ signal } = {},
|
{ signal } = {},
|
||||||
{ gate = limiter, timeoutMs = PHOTO_API_TIMEOUT_MS } = {},
|
{
|
||||||
|
gate = limiter,
|
||||||
|
timeoutMs = PHOTO_API_TIMEOUT_MS,
|
||||||
|
firstTimeoutMs = PHOTO_API_FIRST_TIMEOUT_MS,
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
const query = params ? `?${new URLSearchParams(params)}` : '';
|
const query = params ? `?${new URLSearchParams(params)}` : '';
|
||||||
const url = `${apiHost}${path}${query}`;
|
const url = `${apiHost}${path}${query}`;
|
||||||
@@ -176,7 +203,12 @@ export function createKartaViewSource({
|
|||||||
for (let attempt = 0; ; attempt++) {
|
for (let attempt = 0; ; attempt++) {
|
||||||
try {
|
try {
|
||||||
const payload = await gate.run(
|
const payload = await gate.run(
|
||||||
() => getJson(url, { signal, timeoutMs }),
|
// A slow first answer is usually quick when asked again.
|
||||||
|
() =>
|
||||||
|
getJson(url, {
|
||||||
|
signal,
|
||||||
|
timeoutMs: timeouts === 0 ? firstTimeoutMs : timeoutMs,
|
||||||
|
}),
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
const code = Number(payload?.status?.apiCode);
|
const code = Number(payload?.status?.apiCode);
|
||||||
@@ -300,7 +332,11 @@ export function createKartaViewSource({
|
|||||||
`/2.0/sequence/${encodeURIComponent(sequenceId)}/photos`,
|
`/2.0/sequence/${encodeURIComponent(sequenceId)}/photos`,
|
||||||
{ page: String(page), itemsPerPage: String(itemsPerPage) },
|
{ page: String(page), itemsPerPage: String(itemsPerPage) },
|
||||||
{ signal },
|
{ signal },
|
||||||
{ gate: pageLimiter, timeoutMs: PAGE_TIMEOUT_MS },
|
{
|
||||||
|
gate: pageLimiter,
|
||||||
|
timeoutMs: PAGE_TIMEOUT_MS,
|
||||||
|
firstTimeoutMs: PAGE_TIMEOUT_MS,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
photos: Array.isArray(result.data) ? result.data : [],
|
photos: Array.isArray(result.data) ? result.data : [],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
NEAREST_SEARCH_ZOOM,
|
NEAREST_SEARCH_ZOOM,
|
||||||
PHOTO_API_CONCURRENCY,
|
PHOTO_API_CONCURRENCY,
|
||||||
PHOTO_API_RETRIES,
|
PHOTO_API_RETRIES,
|
||||||
|
PHOTO_API_FIRST_TIMEOUT_MS,
|
||||||
PHOTO_API_TIMEOUT_MS,
|
PHOTO_API_TIMEOUT_MS,
|
||||||
TILE_CONCURRENCY,
|
TILE_CONCURRENCY,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
@@ -402,7 +403,7 @@ test('429 waits double from 0.8 s, wait out a longer Retry-After up to 10 s, and
|
|||||||
assert.deepEqual(await waits(undefined), [2000, 2000, 3200], 'assumed 2 s');
|
assert.deepEqual(await waits(undefined), [2000, 2000, 3200], 'assumed 2 s');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a lookup that hangs is given up after the photo timeout, once more, then reported', async (t) => {
|
test('a lookup that hangs is given up after a short first try, then the full timeout, then reported', async (t) => {
|
||||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
const asked = [];
|
const asked = [];
|
||||||
const outcome = await onMockClock(t, (stamp) =>
|
const outcome = await onMockClock(t, (stamp) =>
|
||||||
@@ -415,9 +416,9 @@ test('a lookup that hangs is given up after the photo timeout, once more, then r
|
|||||||
},
|
},
|
||||||
}).getPhoto('1'),
|
}).getPhoto('1'),
|
||||||
);
|
);
|
||||||
assert.deepEqual(asked, [0, PHOTO_API_TIMEOUT_MS]);
|
assert.deepEqual(asked, [0, PHOTO_API_FIRST_TIMEOUT_MS]);
|
||||||
assert.match(String(outcome?.error?.message), /did not answer in time/);
|
assert.match(String(outcome?.error?.message), /did not answer in time/);
|
||||||
assert.equal(outcome.at, 2 * PHOTO_API_TIMEOUT_MS);
|
assert.equal(outcome.at, PHOTO_API_FIRST_TIMEOUT_MS + PHOTO_API_TIMEOUT_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the nearest lookup asks for the few photos around the point at the search zoom', async () => {
|
test('the nearest lookup asks for the few photos around the point at the search zoom', async () => {
|
||||||
@@ -439,3 +440,49 @@ test('the nearest lookup asks for the few photos around the point at the search
|
|||||||
assert.equal(NEAREST_SEARCH_ZOOM, 18);
|
assert.equal(NEAREST_SEARCH_ZOOM, 18);
|
||||||
assert.equal(NEAREST_LIMIT, 10);
|
assert.equal(NEAREST_LIMIT, 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a chunked answer with no length is cut off once it grows past the cap', async () => {
|
||||||
|
let reads = 0;
|
||||||
|
let cancelled = false;
|
||||||
|
const chunk = new Uint8Array(6 * 1024 * 1024);
|
||||||
|
const source = createKartaViewSource({
|
||||||
|
fetchImpl: async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: new Map(),
|
||||||
|
body: {
|
||||||
|
getReader: () => ({
|
||||||
|
read: async () =>
|
||||||
|
++reads > 10 ? { done: true } : { done: false, value: chunk },
|
||||||
|
cancel: async () => {
|
||||||
|
cancelled = true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await assert.rejects(source.getTile({ x: 1, y: 2, z: 13 }), /too much data/);
|
||||||
|
assert.equal(reads, 3, 'stopped at the third 6 MB chunk');
|
||||||
|
assert.equal(cancelled, true);
|
||||||
|
const small = createKartaViewSource({
|
||||||
|
fetchImpl: async () => {
|
||||||
|
const bytes = new TextEncoder().encode('{"lines":[]}');
|
||||||
|
let given = false;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: new Map(),
|
||||||
|
body: {
|
||||||
|
getReader: () => ({
|
||||||
|
read: async () =>
|
||||||
|
given
|
||||||
|
? { done: true }
|
||||||
|
: ((given = true), { done: false, value: bytes }),
|
||||||
|
cancel: async () => {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(await small.getTile({ x: 1, y: 2, z: 13 }), { lines: [] });
|
||||||
|
});
|
||||||
@@ -83,6 +83,8 @@ export function createKartaViewViewer({
|
|||||||
let drag = null;
|
let drag = null;
|
||||||
let poseFrame = 0;
|
let poseFrame = 0;
|
||||||
let removeListeners = [];
|
let removeListeners = [];
|
||||||
|
/** The document the viewer was built in (new canvases come from it). */
|
||||||
|
let documentOf = null;
|
||||||
/** The photo an open is fetching: ‹ › step on from it, not the old one. */
|
/** The photo an open is fetching: ‹ › step on from it, not the old one. */
|
||||||
let openingId = null;
|
let openingId = null;
|
||||||
/** ‹ › chose this photo: keep the panorama's look direction for it. */
|
/** ‹ › chose this photo: keep the panorama's look direction for it. */
|
||||||
@@ -202,13 +204,35 @@ export function createKartaViewViewer({
|
|||||||
} catch {
|
} catch {
|
||||||
/* already gone */
|
/* already gone */
|
||||||
}
|
}
|
||||||
if (showing === 'pano' && photo) {
|
// A lost canvas hands back its lost context: draw on a new element.
|
||||||
|
const fresh = makeCanvas(documentOf);
|
||||||
|
fresh.hidden = canvas.hidden;
|
||||||
|
canvas.replaceWith?.(fresh);
|
||||||
|
canvas = fresh;
|
||||||
|
// Show the photo again through the core, unless a newer one is opening.
|
||||||
|
if (showing === 'pano' && photo && openingId === null) {
|
||||||
const again = photo.id;
|
const again = photo.id;
|
||||||
show(null);
|
show(null);
|
||||||
adapter.open(again).catch(() => {});
|
keepViewFor = again;
|
||||||
|
openingId = again;
|
||||||
|
onStep(again);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The panorama canvas, wired for drag, pinch, wheel and context loss. */
|
||||||
|
function makeCanvas(doc) {
|
||||||
|
const element = doc.createElement('canvas');
|
||||||
|
element.className = 'sl-kv-pano';
|
||||||
|
element.hidden = true;
|
||||||
|
listen(element, 'pointerdown', onPointerDown);
|
||||||
|
listen(element, 'pointermove', onPointerMove);
|
||||||
|
listen(element, 'pointerup', onPointerUp);
|
||||||
|
listen(element, 'pointercancel', onPointerUp);
|
||||||
|
listen(element, 'wheel', onWheel, { passive: false });
|
||||||
|
listen(element, 'webglcontextlost', onContextLost);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step along the sequence from the photo showing, or from the one on its
|
* Step along the sequence from the photo showing, or from the one on its
|
||||||
* way after an earlier press, and open it through the core.
|
* way after an earlier press, and open it through the core.
|
||||||
@@ -346,6 +370,7 @@ export function createKartaViewViewer({
|
|||||||
|
|
||||||
function build(host) {
|
function build(host) {
|
||||||
const doc = host.ownerDocument;
|
const doc = host.ownerDocument;
|
||||||
|
documentOf = doc;
|
||||||
root = doc.createElement('div');
|
root = doc.createElement('div');
|
||||||
root.className = 'sl-kv-viewer';
|
root.className = 'sl-kv-viewer';
|
||||||
root.tabIndex = 0;
|
root.tabIndex = 0;
|
||||||
@@ -359,9 +384,7 @@ export function createKartaViewViewer({
|
|||||||
picture.alt = 'KartaView street-level photo';
|
picture.alt = 'KartaView street-level photo';
|
||||||
picture.draggable = false;
|
picture.draggable = false;
|
||||||
picture.hidden = true;
|
picture.hidden = true;
|
||||||
canvas = doc.createElement('canvas');
|
canvas = makeCanvas(doc);
|
||||||
canvas.className = 'sl-kv-pano';
|
|
||||||
canvas.hidden = true;
|
|
||||||
const nav = (label, glyph, delta) => {
|
const nav = (label, glyph, delta) => {
|
||||||
const button = doc.createElement('button');
|
const button = doc.createElement('button');
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
@@ -380,12 +403,6 @@ export function createKartaViewViewer({
|
|||||||
nextButton = nav('Next photo in this sequence (→)', '›', 1);
|
nextButton = nav('Next photo in this sequence (→)', '›', 1);
|
||||||
root.append(picture, canvas, prevButton, nextButton);
|
root.append(picture, canvas, prevButton, nextButton);
|
||||||
host.append(root);
|
host.append(root);
|
||||||
listen(canvas, 'pointerdown', onPointerDown);
|
|
||||||
listen(canvas, 'pointermove', onPointerMove);
|
|
||||||
listen(canvas, 'pointerup', onPointerUp);
|
|
||||||
listen(canvas, 'pointercancel', onPointerUp);
|
|
||||||
listen(canvas, 'wheel', onWheel, { passive: false });
|
|
||||||
listen(canvas, 'webglcontextlost', onContextLost);
|
|
||||||
listen(root, 'keydown', onKeyDown);
|
listen(root, 'keydown', onKeyDown);
|
||||||
applyRenderMode();
|
applyRenderMode();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ const PHOTOS = {
|
|||||||
function fakeRenderer(canvas) {
|
function fakeRenderer(canvas) {
|
||||||
const view = { yaw: 0, pitch: 0, fov: 80 };
|
const view = { yaw: 0, pitch: 0, fov: 80 };
|
||||||
return {
|
return {
|
||||||
|
canvas,
|
||||||
images: [],
|
images: [],
|
||||||
hiddenAtSetImage: [],
|
hiddenAtSetImage: [],
|
||||||
setImage(image) {
|
setImage(image) {
|
||||||
@@ -435,16 +436,32 @@ test('two fingers pinch the panorama’s zoom', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('a lost WebGL context drops the panorama and shows the photo again on a new one', async () => {
|
test('a lost WebGL context drops the panorama and shows the photo again on a new one', async () => {
|
||||||
const { viewer, host, renderers, poses } = setup();
|
const { viewer, host, renderers, poses, stepped } = setup();
|
||||||
await viewer.mount(host);
|
await viewer.mount(host);
|
||||||
await viewer.open('pano1');
|
await viewer.open('pano1');
|
||||||
parts(host).canvas.fire('webglcontextlost');
|
const lost = parts(host).canvas;
|
||||||
|
lost.fire('webglcontextlost');
|
||||||
assert.equal(renderers[0].destroyed, true);
|
assert.equal(renderers[0].destroyed, true);
|
||||||
|
// A lost canvas only ever gives back its lost context: a new element.
|
||||||
|
assert.notEqual(parts(host).canvas, lost);
|
||||||
|
assert.deepEqual(stepped, ['pano1'], 'reopened through the core');
|
||||||
assert.ok(await until(() => renderers.length === 2), 'a new renderer');
|
assert.ok(await until(() => renderers.length === 2), 'a new renderer');
|
||||||
|
assert.equal(renderers[1].canvas, parts(host).canvas);
|
||||||
assert.equal(poses.at(-1).imageId, 'pano1');
|
assert.equal(poses.at(-1).imageId, 'pano1');
|
||||||
assert.equal(parts(host).canvas.hidden, false);
|
assert.equal(parts(host).canvas.hidden, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a context lost while a newer photo opens leaves that open alone', async () => {
|
||||||
|
const { viewer, host, stepped } = setup();
|
||||||
|
await viewer.mount(host);
|
||||||
|
await viewer.open('pano1');
|
||||||
|
const opening = viewer.open('flat1');
|
||||||
|
parts(host).canvas.fire('webglcontextlost');
|
||||||
|
await opening;
|
||||||
|
assert.deepEqual(stepped, [], 'no reopen of the old panorama');
|
||||||
|
assert.equal(parts(host).picture.hidden, false);
|
||||||
|
});
|
||||||
|
|
||||||
test('a WebGL failure only lasts until the viewer is mounted again', async () => {
|
test('a WebGL failure only lasts until the viewer is mounted again', async () => {
|
||||||
let fail = true;
|
let fail = true;
|
||||||
const built = [];
|
const built = [];
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ export function fakeElement(tag) {
|
|||||||
if (list) list.splice(list.indexOf(element), 1);
|
if (list) list.splice(list.indexOf(element), 1);
|
||||||
element.parentNode = null;
|
element.parentNode = null;
|
||||||
},
|
},
|
||||||
|
replaceWith(next) {
|
||||||
|
const parent = element.parentNode;
|
||||||
|
if (!parent) return;
|
||||||
|
parent.children[parent.children.indexOf(element)] = next;
|
||||||
|
next.parentNode = parent;
|
||||||
|
next.ownerDocument = element.ownerDocument;
|
||||||
|
element.parentNode = null;
|
||||||
|
},
|
||||||
setAttribute: (name, value) => (element.attributes[name] = value),
|
setAttribute: (name, value) => (element.attributes[name] = value),
|
||||||
removeAttribute: (name) => {
|
removeAttribute: (name) => {
|
||||||
delete element.attributes[name];
|
delete element.attributes[name];
|
||||||
|
|||||||
Reference in new issue
Block a user