From b365aad4c181d3f237f1e6f538bfcfc6e87f555e Mon Sep 17 00:00:00 2001
From: Martin Trapp <94928215+martrapp@users.noreply.github.com>
Date: Sun, 1 Sep 2024 12:19:39 +0200
Subject: [PATCH] snapshot
---
.gitignore | 1 +
README.md | 8 +--
bin/bundle | 2 +-
package.json | 10 ++-
src/index.ts | 24 -------
src/over-the-top.ts | 103 +++++++++++++++++++++++++++
src/types.ts | 10 +++
src/vanilla.ts | 169 ++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 296 insertions(+), 31 deletions(-)
delete mode 100644 src/index.ts
create mode 100644 src/over-the-top.ts
create mode 100644 src/vanilla.ts
diff --git a/.gitignore b/.gitignore
index 491fc35..19d93d2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
node_modules
lib
+.astro
\ No newline at end of file
diff --git a/README.md b/README.md
index c2559b6..4e2c274 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-# ElementCrossing
-Preserve what matters across document transitions!
+# 🚸 ElementCrossing
+Transfer selected element states across cross-document view transitions.
![Build Status](https://github.com/vtbag/element-crossing/actions/workflows/run-tests.yml/badge.svg)
[![npm version](https://img.shields.io/npm/v/@vtbag/element-crossing/latest)](https://www.npmjs.com/package/@vtbag/element-crossing)
@@ -9,8 +9,8 @@ The @vtbag website can be found at https://vtbag.pages.dev/
## !!! News !!!
-## What happened so far:
+First official release of this code!
## What is it?
-This library provides a robust solution for maintaining HTML elements and their associated states across cross-document view transitions. It is designed to enhance the user experience by ensuring that important elements and their current states are preserved as users navigate through different pages or documents.
+This library provides a robust solution for maintaining HTML elements and their associated state across cross-document view transitions.
diff --git a/bin/bundle b/bin/bundle
index b4c03e5..5754ebe 100755
--- a/bin/bundle
+++ b/bin/bundle
@@ -6,4 +6,4 @@ else
OPTS="--minify"
(cd lib && rm *.js.map) >> /dev/null 2>&1
fi
-npx esbuild src/index.ts --bundle $OPTS --target=ESnext --outfile=lib/index.js
+npx esbuild src/vanilla.ts src/over-the-top.ts --bundle $OPTS --target=ESnext --outdir=lib
diff --git a/package.json b/package.json
index 5710593..2a7304a 100644
--- a/package.json
+++ b/package.json
@@ -1,11 +1,17 @@
{
"name": "@vtbag/element-crossing",
"version": "0.0.0",
- "main": "lib/index.js",
+ "main": "lib/vanilla.js",
"description": "Sites using cross-document view transitions look like SPAs but they still do full page loads on navigation. The element crossing provides a way to preserve DOM elements and data objects across page loads.",
"files": [
- "lib/index.js"
+ "lib/vanilla.js",
+ "lib/over-the-top.js"
],
+ "exports": {
+ ".": "./lib/vanilla.js",
+ "./vanilla": "./lib/vanilla.js",
+ "./over-the-top": "./lib/over-the-top.js"
+ },
"scripts": {
"dev": "bin/bundle dev",
"build": "npm run format; bin/bundle",
diff --git a/src/index.ts b/src/index.ts
deleted file mode 100644
index b1b1be0..0000000
--- a/src/index.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { ElementCrossing } from './types';
-
-top!.__vtbag ??= {};
-top!.__vtbag.elementCrossing ??= {};
-const elementCrossing = top!.__vtbag.elementCrossing!;
-
-if (top === self) {
- initBorderLands();
-} else {
- initHeartLand();
-}
-
-function initBorderLands() {}
-
-function initHeartLand() {
- const frameDocument = (elementCrossing.frameDocument = self.document);
-
- self.addEventListener('pageswap', pageSwap, { once: true });
- self.addEventListener('pagereveal', pageReveal, { once: true });
-}
-
-function pageSwap() {}
-
-function pageReveal() {}
diff --git a/src/over-the-top.ts b/src/over-the-top.ts
new file mode 100644
index 0000000..6501560
--- /dev/null
+++ b/src/over-the-top.ts
@@ -0,0 +1,103 @@
+import { Spec } from './types';
+
+top!.__vtbag ??= {};
+top!.__vtbag.elementCrossing ??= {};
+const elementCrossing = top!.__vtbag.elementCrossing!;
+
+console.log('[elc]', 'init');
+if (top === self) {
+ initBorderLands();
+} else if (self.parent === top) {
+ initHeartLand();
+} else {
+ console.log('[elc]', 'neither BorderLands nor HeartLand');
+}
+
+function initBorderLands() {
+ console.log('[elc]', 'init BorderLands');
+ addEventListener('pagereveal', () => {
+ console.log('[elc]', 'DOMContentLoaded');
+ const topDoc = top!.document;
+ const root = topDoc.documentElement;
+ root.innerHTML = `
`;
+ root.style.overflow = 'clip';
+ });
+}
+
+function initHeartLand() {
+ console.log('[elc]', 'init HeartLand');
+ self.addEventListener('pageswap', pageSwap, { once: true });
+ self.addEventListener('pagereveal', pageReveal, { once: true });
+}
+
+function pageSwap() {
+ console.log('[elc]', 'pageSwap');
+
+ self.document.querySelectorAll('[data-vtbag-x]').forEach((el) => {
+ let id;
+ const specs: Spec[] = [];
+ el
+ .getAttribute('data-vtbag-x')
+ ?.split(' ')
+ .forEach((value) => {
+ const [kind, key] = kindAndKey(value);
+ switch (kind) {
+ case 'id':
+ id = key;
+ break;
+ case 'class':
+ specs.push({ kind, key, value: el.classList.contains(key) ? 'true' : 'false' });
+ break;
+ case 'style':
+ specs.push({
+ kind,
+ key,
+ value: '' + (el.style[key as keyof CSSStyleDeclaration] ?? ''),
+ });
+ break;
+ case 'attr':
+ specs.push({ kind, key, value: el.getAttribute(key) ?? '' });
+ break;
+ default:
+ console.error('[crossing]', 'unknown kind', kind);
+ break;
+ }
+ });
+ });
+}
+
+function pageReveal() {
+ console.log('[elc]', 'pageReveal');
+ const topDoc = top!.document;
+ topDoc.title = document.title;
+ top!.history.replaceState({}, '', location.href);
+}
+
+function kindAndKey(value: string) {
+ let [kind, key] = value.split(':');
+ if (key === undefined) {
+ key = kind.slice(1);
+ switch (kind[0]) {
+ case '#':
+ kind = 'id';
+ break;
+ case '.':
+ kind = 'class';
+ break;
+ case '@':
+ kind = 'attr';
+ break;
+ case '-':
+ kind = 'style';
+ break;
+ default:
+ console.error(
+ '[crossing]',
+ 'syntax error:',
+ value,
+ 'is not recognized as a valid specification'
+ );
+ }
+ }
+ return [kind, key];
+}
diff --git a/src/types.ts b/src/types.ts
index befbec2..84a1c56 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -9,3 +9,13 @@ declare global {
};
}
}
+
+export type Spec = {
+ kind: string;
+ key: string;
+ value?: string;
+};
+export type ElementSpec = {
+ id: string;
+ specs: Spec[];
+};
diff --git a/src/vanilla.ts b/src/vanilla.ts
new file mode 100644
index 0000000..b823220
--- /dev/null
+++ b/src/vanilla.ts
@@ -0,0 +1,169 @@
+import { ElementSpec, Spec } from './types';
+
+top!.__vtbag ??= {};
+top!.__vtbag.elementCrossing ??= {};
+
+init();
+
+function init() {
+ self.addEventListener("onpageswap" in self ? 'pageswap' : 'pagehide', pageSwap, { once: true });
+ self.addEventListener("onpagereveal" in self ? 'pagereveal' : 'DOMContentLoaded', pageReveal, { once: true });
+}
+
+function pageSwap() {
+ console.log('pageSwap');
+ sessionStorage.setItem('@vtbag/element-crossing', JSON.stringify(retrieve()));
+}
+
+function pageReveal() {
+ console.log('pageReveal');
+ const values = sessionStorage.getItem('@vtbag/element-crossing');
+ console.log(values);
+ restore(JSON.parse(values ?? "[]"));
+}
+
+function retrieve() {
+ const known = new Set();
+ const values: ElementSpec[] = [];
+ self.document
+ .querySelectorAll('[data-vtbag-x]')
+ .forEach((element) => {
+ const spec = elementSpec(element);
+ if (spec) {
+ if (known.has(spec.id)) console.error('[crossing]', 'non unique id', spec.id);
+ values.push(spec);
+ known.add(spec.id);
+ }
+ });
+ return values;
+}
+
+function elementSpec(element: HTMLElement) {
+ function kindAndKey(value: string) {
+ let [kind, key] = value.split(':', 2);
+ if (key === undefined) {
+ key = kind.slice(1);
+ switch (kind[0]) {
+ case '#':
+ kind = 'id';
+ break;
+ case '.':
+ kind = 'class';
+ break;
+ case '@':
+ kind = 'prop';
+ break;
+ case '-':
+ kind = 'style';
+ break;
+ case '~':
+ kind = 'anim';
+ break;
+ default:
+ console.error(
+ '[crossing]',
+ 'syntax error:',
+ value,
+ 'is not recognized as a valid specification'
+ );
+ }
+ }
+ return [kind, key];
+ }
+
+ let id = element.id;
+ const specs: Spec[] = [];
+ element
+ .getAttribute('data-vtbag-x')
+ ?.split(' ')
+ .forEach((value) => {
+ const [kind, key] = kindAndKey(value);
+ switch (kind) {
+ case 'id':
+ id = key;
+ break;
+ case 'class':
+ specs.push({ kind, key, value: element.classList.contains(key) ? 'true' : 'false' });
+ break;
+ case 'style':
+ specs.push({
+ kind,
+ key,
+ value: '' + (element.style[key as keyof CSSStyleDeclaration] ?? ''),
+ });
+ break;
+ case 'bool':
+ case 'num':
+ case 'prop':
+ const val = element[key as keyof HTMLElement];
+ const type = typeof val;
+ specs.push({ kind: type === "boolean" ? "bool" : (type === "number" ? "num" : "prop"), key, value: "" + val });
+ break;
+ case 'anim':
+ const animations = element.getAnimations().filter(a => a instanceof CSSAnimation && a.animationName === key);
+ if (animations.length > 1) {
+ console.error("[crossing]", `retrieval: animation name ${key} is not unique for`, element);
+ }
+ if (animations.length > 0) {
+ specs.push({ kind, key, value: "" + (animations[0].currentTime ?? 0) });
+ } break;
+ default:
+ console.error('[crossing]', 'unknown kind', kind);
+ break;
+ }
+ });
+ if (id === undefined) console.error('[crossing]', 'missing id in', element);
+ else return { id, specs };
+}
+
+function restore(values: ElementSpec[]) {
+ values.forEach((elementSpec: ElementSpec) => {
+ const element = document.querySelector(
+ "#" + elementSpec.id +
+ ",[data-vtbag-x*='#" + elementSpec.id +
+ "'],[data-vtbag-x*='id:" + elementSpec.id +
+ "']"
+ );
+ if (!element) return;
+ elementSpec.specs.forEach((s) => {
+ switch (s.kind) {
+ case 'class':
+ element.classList[s.value === 'true' ? 'add' : 'remove'](s.key);
+ break;
+ case 'style':
+ if (s.key === 'length' || s.key === 'parentRule') {
+ console.error(
+ '[crossing]',
+ 'Cannot assign to read-only property',
+ s.key,
+ 'in',
+ elementSpec.id
+ );
+ } else {
+ element.style.setProperty(s.key, s.value ?? '');
+ }
+ break;
+ case 'prop':
+ (element as any)[s.key] = s.value;
+ break;
+ case 'bool':
+ (element as any)[s.key] = s.value === "true";
+ break;
+ case 'num':
+ (element as any)[s.key] = parseFloat(s.value ?? "0");
+ break;
+ case 'anim':
+ const animations = element.getAnimations().filter(a => a instanceof CSSAnimation && a.animationName === s.key);
+ if (animations.length > 1) {
+ console.warn("[crossing]", `restore: animation name ${s.key} is not unique for`, element);
+ }
+ animations.forEach(a => a.currentTime = ~~(s.value ?? "0"));
+ element.setAttribute(s.key, s.value ?? '');
+ break;
+ default:
+ console.error('[crossing]', 'unknown kind', s.kind);
+ break;
+ }
+ });
+ });
+}