pomium 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -0
- package/assets/atlases.json +38 -0
- package/assets/desktop/char_01_desktop.json +783 -0
- package/assets/desktop/char_01_desktop.webp +0 -0
- package/assets/desktop/char_02_desktop.json +783 -0
- package/assets/desktop/char_02_desktop.webp +0 -0
- package/assets/desktop/char_03_desktop.json +783 -0
- package/assets/desktop/char_03_desktop.webp +0 -0
- package/assets/desktop/char_04_desktop.json +783 -0
- package/assets/desktop/char_04_desktop.webp +0 -0
- package/assets/desktop/char_05_desktop.json +783 -0
- package/assets/desktop/char_05_desktop.webp +0 -0
- package/assets/desktop/char_06_desktop.json +783 -0
- package/assets/desktop/char_06_desktop.webp +0 -0
- package/assets/desktop/char_07_desktop.json +783 -0
- package/assets/desktop/char_07_desktop.webp +0 -0
- package/assets/desktop/char_08_desktop.json +783 -0
- package/assets/desktop/char_08_desktop.webp +0 -0
- package/assets/desktop/char_09_desktop.json +783 -0
- package/assets/desktop/char_09_desktop.webp +0 -0
- package/assets/desktop/char_10_desktop.json +783 -0
- package/assets/desktop/char_10_desktop.webp +0 -0
- package/assets/desktop/shockwave_desktop.json +151 -0
- package/assets/desktop/shockwave_desktop.webp +0 -0
- package/assets/mobile/char_01_mobile.json +783 -0
- package/assets/mobile/char_01_mobile.webp +0 -0
- package/assets/mobile/char_02_mobile.json +783 -0
- package/assets/mobile/char_02_mobile.webp +0 -0
- package/assets/mobile/char_03_mobile.json +783 -0
- package/assets/mobile/char_03_mobile.webp +0 -0
- package/assets/mobile/char_04_mobile.json +783 -0
- package/assets/mobile/char_04_mobile.webp +0 -0
- package/assets/mobile/char_05_mobile.json +783 -0
- package/assets/mobile/char_05_mobile.webp +0 -0
- package/assets/mobile/char_06_mobile.json +783 -0
- package/assets/mobile/char_06_mobile.webp +0 -0
- package/assets/mobile/char_07_mobile.json +783 -0
- package/assets/mobile/char_07_mobile.webp +0 -0
- package/assets/mobile/char_08_mobile.json +783 -0
- package/assets/mobile/char_08_mobile.webp +0 -0
- package/assets/mobile/char_09_mobile.json +783 -0
- package/assets/mobile/char_09_mobile.webp +0 -0
- package/assets/mobile/char_10_mobile.json +783 -0
- package/assets/mobile/char_10_mobile.webp +0 -0
- package/assets/mobile/shockwave_mobile.json +151 -0
- package/assets/mobile/shockwave_mobile.webp +0 -0
- package/bin/pomium.js +20 -0
- package/package.json +32 -0
- package/src/main.js +114 -0
- package/src/pom/atlas-loader.js +21 -0
- package/src/pom/atlas.js +83 -0
- package/src/pom/config.js +41 -0
- package/src/pom/coords.js +10 -0
- package/src/pom/engine.js +103 -0
- package/src/pom/geometry.js +43 -0
- package/src/pom/index.js +173 -0
- package/src/pom/loop.js +68 -0
- package/src/pom/overlay.js +42 -0
- package/src/pom/package.json +1 -0
- package/src/pom/render.js +32 -0
- package/src/pom/sprites.js +66 -0
- package/src/renderer/index.html +25 -0
- package/src/renderer/renderer.js +197 -0
- package/src/renderer/styles.css +148 -0
- package/src/webview-preload.js +68 -0
package/src/pom/index.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { IDLE_TEARDOWN_MS, ATLAS_CACHE_LIMIT } from './config.js';
|
|
2
|
+
import { attachOverlay } from './overlay.js';
|
|
3
|
+
import { createEngine, createRoster } from './engine.js';
|
|
4
|
+
import { createAtlasCache } from './atlas.js';
|
|
5
|
+
import { createAtlasLoader } from './atlas-loader.js';
|
|
6
|
+
import { createLoop } from './loop.js';
|
|
7
|
+
import { renderScene } from './render.js';
|
|
8
|
+
|
|
9
|
+
// Pre-decode defaults used only to size a sprite before its atlas has
|
|
10
|
+
// loaded; the decoded atlas is the real source of truth once it lands.
|
|
11
|
+
const FRAME_COUNTS = { shockwave: 17 };
|
|
12
|
+
const DEFAULT_CHARACTER_FRAMES = 96;
|
|
13
|
+
|
|
14
|
+
// A desktop browser window is never below the mobile breakpoint.
|
|
15
|
+
const TIER = 'desktop';
|
|
16
|
+
|
|
17
|
+
export function createPoms({ canvas, assetsDir, onShake }) {
|
|
18
|
+
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
19
|
+
return { pointerDown() {}, pointerMove() {}, pointerUp() {}, resize() {}, destroy() {} };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let overlay = null;
|
|
23
|
+
let engine = null;
|
|
24
|
+
let loop = null;
|
|
25
|
+
let cache = null;
|
|
26
|
+
let roster = null;
|
|
27
|
+
let idleTimer = null;
|
|
28
|
+
let resizeHandle = null;
|
|
29
|
+
// Last offset handed to onShake. The shake decays in SHAKE_DURATION updates
|
|
30
|
+
// but sprites live far longer, so an unconditional send would repeat the
|
|
31
|
+
// same settled payload across the process boundary for the rest of their
|
|
32
|
+
// life and the whole idle window after it.
|
|
33
|
+
let lastShake = null;
|
|
34
|
+
|
|
35
|
+
function emitShake(x, y, settled) {
|
|
36
|
+
if (!onShake) return;
|
|
37
|
+
if (lastShake && lastShake.x === x && lastShake.y === y && lastShake.settled === settled) return;
|
|
38
|
+
lastShake = { x, y, settled };
|
|
39
|
+
onShake(x, y, settled);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function frameCountFor(key) {
|
|
43
|
+
return FRAME_COUNTS[key] ?? DEFAULT_CHARACTER_FRAMES;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function warm(key) {
|
|
47
|
+
cache.get(key).catch(() => {});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A transient decode failure would otherwise kill that character for the
|
|
51
|
+
// session, since the renderer only ever peeks the cache. Re-warm whatever
|
|
52
|
+
// the roster is missing on each press, so a failure heals on the next click.
|
|
53
|
+
function rewarmMissing() {
|
|
54
|
+
if (!cache || !roster) return;
|
|
55
|
+
if (!cache.peek('shockwave')) warm('shockwave');
|
|
56
|
+
for (const key of roster) {
|
|
57
|
+
if (!cache.peek(key)) warm(key);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function teardown() {
|
|
62
|
+
if (loop) loop.stop();
|
|
63
|
+
if (overlay) overlay.destroy();
|
|
64
|
+
if (cache) cache.clear();
|
|
65
|
+
if (idleTimer !== null) {
|
|
66
|
+
clearTimeout(idleTimer);
|
|
67
|
+
idleTimer = null;
|
|
68
|
+
}
|
|
69
|
+
if (resizeHandle !== null) {
|
|
70
|
+
cancelAnimationFrame(resizeHandle);
|
|
71
|
+
resizeHandle = null;
|
|
72
|
+
}
|
|
73
|
+
overlay = null; engine = null; loop = null; cache = null;
|
|
74
|
+
roster = null;
|
|
75
|
+
lastShake = null;
|
|
76
|
+
emitShake(0, 0, true);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function scheduleIdleCheck() {
|
|
80
|
+
if (idleTimer !== null) clearTimeout(idleTimer);
|
|
81
|
+
idleTimer = setTimeout(() => {
|
|
82
|
+
if (engine && engine.isIdle) teardown();
|
|
83
|
+
else if (engine) scheduleIdleCheck();
|
|
84
|
+
}, IDLE_TEARDOWN_MS);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function ensureStarted() {
|
|
88
|
+
if (engine) {
|
|
89
|
+
if (!loop.running) loop.start();
|
|
90
|
+
scheduleIdleCheck();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
overlay = attachOverlay(canvas);
|
|
96
|
+
cache = createAtlasCache({
|
|
97
|
+
limit: ATLAS_CACHE_LIMIT,
|
|
98
|
+
load: createAtlasLoader({ tier: TIER, assetsDir }),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// One roster per session, so at most ROSTER_SIZE + 1 atlases are live.
|
|
102
|
+
roster = createRoster();
|
|
103
|
+
warm('shockwave');
|
|
104
|
+
for (const key of roster) warm(key);
|
|
105
|
+
|
|
106
|
+
engine = createEngine({
|
|
107
|
+
width: overlay.width,
|
|
108
|
+
height: overlay.height,
|
|
109
|
+
roster,
|
|
110
|
+
frameCountFor,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
loop = createLoop({
|
|
114
|
+
update: () => {
|
|
115
|
+
engine.update();
|
|
116
|
+
// The window takes x/y; the canvas takes rotation. Sending both to
|
|
117
|
+
// both would double the shake.
|
|
118
|
+
// isIdle means "no sprites left" — 96 frames — but the shake decays in 8
|
|
119
|
+
// updates. Treating a zero offset as settled releases the window as soon as
|
|
120
|
+
// the jolt actually ends, instead of pinning it for the sprite's whole life.
|
|
121
|
+
const settled = engine.isIdle || (engine.camera.x === 0 && engine.camera.y === 0);
|
|
122
|
+
emitShake(engine.camera.x, engine.camera.y, settled);
|
|
123
|
+
if (engine.isIdle) loop.stop();
|
|
124
|
+
},
|
|
125
|
+
render: () => renderScene(overlay.ctx, {
|
|
126
|
+
sprites: engine.sprites,
|
|
127
|
+
camera: { x: 0, y: 0, rotation: engine.camera.rotation },
|
|
128
|
+
atlases: { get: (key) => cache.peek(key) },
|
|
129
|
+
width: overlay.width,
|
|
130
|
+
height: overlay.height,
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
loop.start();
|
|
134
|
+
scheduleIdleCheck();
|
|
135
|
+
} catch (error) {
|
|
136
|
+
// Never break the browser chrome. If any of this throws, leaving engine
|
|
137
|
+
// non-null would throw again on every later click, forever.
|
|
138
|
+
console.warn('[pomium] failed to start', error);
|
|
139
|
+
if (overlay) overlay.destroy();
|
|
140
|
+
if (cache) cache.clear();
|
|
141
|
+
overlay = null; engine = null; loop = null; cache = null; roster = null;
|
|
142
|
+
lastShake = null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
pointerDown(x) {
|
|
148
|
+
ensureStarted();
|
|
149
|
+
if (!engine) return;
|
|
150
|
+
rewarmMissing();
|
|
151
|
+
engine.pointerDown(x);
|
|
152
|
+
},
|
|
153
|
+
pointerMove(x) {
|
|
154
|
+
if (engine) engine.pointerMove(x);
|
|
155
|
+
},
|
|
156
|
+
pointerUp() {
|
|
157
|
+
if (engine) engine.pointerUp();
|
|
158
|
+
},
|
|
159
|
+
resize() {
|
|
160
|
+
if (!overlay || !engine) return;
|
|
161
|
+
// The window fires resize continuously during a drag, and each resize
|
|
162
|
+
// reallocates a full-viewport backing store. Coalesce to one per frame.
|
|
163
|
+
if (resizeHandle !== null) return;
|
|
164
|
+
resizeHandle = requestAnimationFrame(() => {
|
|
165
|
+
resizeHandle = null;
|
|
166
|
+
if (!overlay || !engine) return;
|
|
167
|
+
overlay.resize();
|
|
168
|
+
engine.resize(overlay.width, overlay.height);
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
destroy: teardown,
|
|
172
|
+
};
|
|
173
|
+
}
|
package/src/pom/loop.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { UPDATES_PER_SECOND } from './config.js';
|
|
2
|
+
|
|
3
|
+
const MAX_CATCH_UP = 5;
|
|
4
|
+
|
|
5
|
+
// Fixed-timestep loop: update() always advances the simulation by `step` ms
|
|
6
|
+
// regardless of the display's refresh rate. The reference implementation
|
|
7
|
+
// this replaces steps its simulation on every second tick of a 60Hz ticker,
|
|
8
|
+
// so it silently runs at double speed on a 120Hz display. Accumulating real
|
|
9
|
+
// elapsed time and draining it in fixed `step` increments keeps the
|
|
10
|
+
// simulation rate constant across refresh rates, while render() still runs
|
|
11
|
+
// once per scheduled frame so drawing stays at the display's native rate.
|
|
12
|
+
export function createLoop({
|
|
13
|
+
update,
|
|
14
|
+
render,
|
|
15
|
+
step = 1000 / UPDATES_PER_SECOND,
|
|
16
|
+
now = () => performance.now(),
|
|
17
|
+
schedule = (fn) => requestAnimationFrame(fn),
|
|
18
|
+
cancel = (handle) => cancelAnimationFrame(handle),
|
|
19
|
+
}) {
|
|
20
|
+
let handle = null;
|
|
21
|
+
let running = false;
|
|
22
|
+
let last = 0;
|
|
23
|
+
let accumulator = 0;
|
|
24
|
+
|
|
25
|
+
function frame() {
|
|
26
|
+
handle = null;
|
|
27
|
+
const time = now();
|
|
28
|
+
accumulator += time - last;
|
|
29
|
+
last = time;
|
|
30
|
+
|
|
31
|
+
let steps = 0;
|
|
32
|
+
while (accumulator >= step && steps < MAX_CATCH_UP) {
|
|
33
|
+
accumulator -= step;
|
|
34
|
+
steps += 1;
|
|
35
|
+
update();
|
|
36
|
+
}
|
|
37
|
+
if (steps === MAX_CATCH_UP) accumulator = 0;
|
|
38
|
+
|
|
39
|
+
render();
|
|
40
|
+
|
|
41
|
+
// update() or render() may have called stop() re-entrantly (e.g. the
|
|
42
|
+
// engine going idle mid-update). running is the source of truth for
|
|
43
|
+
// whether this frame should reschedule itself; handle alone can't tell
|
|
44
|
+
// the difference between "never started" and "stopped mid-callback",
|
|
45
|
+
// since it was already nulled above.
|
|
46
|
+
if (running) {
|
|
47
|
+
handle = schedule(frame);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
start() {
|
|
53
|
+
if (running) return;
|
|
54
|
+
running = true;
|
|
55
|
+
last = now();
|
|
56
|
+
accumulator = 0;
|
|
57
|
+
handle = schedule(frame);
|
|
58
|
+
},
|
|
59
|
+
stop() {
|
|
60
|
+
running = false;
|
|
61
|
+
if (handle !== null) cancel(handle);
|
|
62
|
+
handle = null;
|
|
63
|
+
},
|
|
64
|
+
get running() {
|
|
65
|
+
return running;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function canvasBackingSize(cssWidth, cssHeight, dpr) {
|
|
2
|
+
const ratio = Math.min(3, Math.max(1, dpr || 1));
|
|
3
|
+
return {
|
|
4
|
+
width: Math.floor(cssWidth * ratio),
|
|
5
|
+
height: Math.floor(cssHeight * ratio),
|
|
6
|
+
dpr: ratio,
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// The canvas already exists in the chrome's own HTML, so unlike the browser
|
|
11
|
+
// extension this does not build a host element or a shadow root: there is no
|
|
12
|
+
// hostile page CSS to defend against here. It only measures and sizes.
|
|
13
|
+
export function attachOverlay(canvas) {
|
|
14
|
+
const ctx = canvas.getContext('2d', { alpha: true });
|
|
15
|
+
let destroyed = false;
|
|
16
|
+
|
|
17
|
+
const overlay = {
|
|
18
|
+
canvas,
|
|
19
|
+
ctx,
|
|
20
|
+
width: 0,
|
|
21
|
+
height: 0,
|
|
22
|
+
resize() {
|
|
23
|
+
if (destroyed) return;
|
|
24
|
+
const rect = canvas.getBoundingClientRect();
|
|
25
|
+
const cssWidth = rect.width || window.innerWidth;
|
|
26
|
+
const cssHeight = rect.height || window.innerHeight;
|
|
27
|
+
const size = canvasBackingSize(cssWidth, cssHeight, window.devicePixelRatio);
|
|
28
|
+
canvas.width = size.width;
|
|
29
|
+
canvas.height = size.height;
|
|
30
|
+
ctx.setTransform(size.dpr, 0, 0, size.dpr, 0, 0);
|
|
31
|
+
overlay.width = cssWidth;
|
|
32
|
+
overlay.height = cssHeight;
|
|
33
|
+
},
|
|
34
|
+
destroy() {
|
|
35
|
+
destroyed = true;
|
|
36
|
+
ctx.clearRect(0, 0, overlay.width, overlay.height);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
overlay.resize();
|
|
41
|
+
return overlay;
|
|
42
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "type": "module" }
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { POM_ANCHOR, SHOCKWAVE_ANCHOR } from './config.js';
|
|
2
|
+
|
|
3
|
+
export function spriteDrawArgs(sprite, frame) {
|
|
4
|
+
const anchor = sprite.isShockwave ? SHOCKWAVE_ANCHOR : POM_ANCHOR;
|
|
5
|
+
const scale = sprite.scale;
|
|
6
|
+
return {
|
|
7
|
+
sx: frame.sx, sy: frame.sy, sw: frame.sw, sh: frame.sh,
|
|
8
|
+
dx: Math.round(sprite.x - anchor.x * frame.sourceW * scale + frame.ox * scale),
|
|
9
|
+
dy: Math.round(sprite.y - anchor.y * frame.sourceH * scale + frame.oy * scale),
|
|
10
|
+
dw: frame.sw * scale,
|
|
11
|
+
dh: frame.sh * scale,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function renderScene(ctx, { sprites, camera, atlases, width, height }) {
|
|
16
|
+
ctx.clearRect(0, 0, width, height);
|
|
17
|
+
ctx.save();
|
|
18
|
+
ctx.translate(width / 2 + camera.x, height / 2 + camera.y);
|
|
19
|
+
ctx.rotate(camera.rotation);
|
|
20
|
+
ctx.translate(-width / 2, -height / 2);
|
|
21
|
+
|
|
22
|
+
for (const sprite of sprites) {
|
|
23
|
+
const atlas = atlases.get(sprite.key);
|
|
24
|
+
if (!atlas) continue;
|
|
25
|
+
const frame = atlas.frames[sprite.frame];
|
|
26
|
+
if (!frame) continue;
|
|
27
|
+
const a = spriteDrawArgs(sprite, frame);
|
|
28
|
+
ctx.drawImage(atlas.image, a.sx, a.sy, a.sw, a.sh, a.dx, a.dy, a.dw, a.dh);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
ctx.restore();
|
|
32
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { CULL_MARGIN, MAX_ACTIVE } from './config.js';
|
|
2
|
+
import { depthFactor, scaleForDepth, speedMultForDepth } from './geometry.js';
|
|
3
|
+
|
|
4
|
+
export function createSprite({ key, isShockwave, x, y, vx, vy, frameCount }) {
|
|
5
|
+
return {
|
|
6
|
+
key, isShockwave, x, y, vx, vy, frameCount,
|
|
7
|
+
frame: 0, scale: 0.5, zIndex: 0, retired: false,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function integrate(sprite, height) {
|
|
12
|
+
const depth = depthFactor(sprite.y, height);
|
|
13
|
+
const scale = scaleForDepth(depth);
|
|
14
|
+
const speedMult = speedMultForDepth(depth);
|
|
15
|
+
sprite.scale = scale;
|
|
16
|
+
sprite.zIndex = sprite.isShockwave ? scale - 10 : scale;
|
|
17
|
+
sprite.x += sprite.vx * speedMult * 2;
|
|
18
|
+
sprite.y += sprite.vy * speedMult * 2;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function advanceFrame(sprite) {
|
|
22
|
+
if (sprite.frame < sprite.frameCount - 1) {
|
|
23
|
+
sprite.frame += 1;
|
|
24
|
+
} else {
|
|
25
|
+
sprite.retired = true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isCulled(sprite, width, height) {
|
|
30
|
+
return (
|
|
31
|
+
sprite.x > width + CULL_MARGIN || sprite.x < -CULL_MARGIN ||
|
|
32
|
+
sprite.y > height + CULL_MARGIN || sprite.y < -CULL_MARGIN
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class SpriteList {
|
|
37
|
+
constructor(limit = MAX_ACTIVE) {
|
|
38
|
+
this.limit = limit;
|
|
39
|
+
this.items = [];
|
|
40
|
+
this.nextSeq = 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
add(sprite) {
|
|
44
|
+
// Insertion order is tracked explicitly via a monotonic sequence number,
|
|
45
|
+
// not inferred from array position: engine.update() sorts this.items by
|
|
46
|
+
// zIndex in place every tick (draw order), so by the time add() runs
|
|
47
|
+
// again the array is no longer in insertion order. Evicting by splice(0,
|
|
48
|
+
// n) against that sorted array evicted whatever a shockwave's zIndex
|
|
49
|
+
// (scale - 10) put first, not the oldest sprite.
|
|
50
|
+
sprite.seq = this.nextSeq;
|
|
51
|
+
this.nextSeq += 1;
|
|
52
|
+
this.items.push(sprite);
|
|
53
|
+
if (this.items.length > this.limit) {
|
|
54
|
+
const excess = this.items.length - this.limit;
|
|
55
|
+
const oldest = [...this.items].sort((a, b) => a.seq - b.seq).slice(0, excess);
|
|
56
|
+
const evict = new Set(oldest);
|
|
57
|
+
this.items = this.items.filter((s) => !evict.has(s));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
prune(width, height) {
|
|
62
|
+
this.items = this.items.filter(
|
|
63
|
+
(s) => !s.retired && !isCulled(s, width, height)
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<title>Pomium</title>
|
|
6
|
+
<link rel="stylesheet" href="./styles.css" />
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="tab-strip">
|
|
10
|
+
<div id="tabs"></div>
|
|
11
|
+
<button id="new-tab-btn" title="New tab">+</button>
|
|
12
|
+
</div>
|
|
13
|
+
<div id="toolbar">
|
|
14
|
+
<button id="back-btn" title="Back">←</button>
|
|
15
|
+
<button id="fwd-btn" title="Forward">→</button>
|
|
16
|
+
<button id="reload-btn" title="Reload">↻</button>
|
|
17
|
+
<input id="address-bar" type="text" placeholder="Search or enter address" />
|
|
18
|
+
<button id="go-btn" title="Go">Go</button>
|
|
19
|
+
</div>
|
|
20
|
+
<div id="content"></div>
|
|
21
|
+
<canvas id="pom-overlay"></canvas>
|
|
22
|
+
|
|
23
|
+
<script src="./renderer.js"></script>
|
|
24
|
+
</body>
|
|
25
|
+
</html>
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const { ipcRenderer } = require('electron');
|
|
3
|
+
|
|
4
|
+
// src/pom/ is ESM (scoped by its own package.json) while this file is
|
|
5
|
+
// CommonJS, so it is pulled in with a dynamic import.
|
|
6
|
+
let poms = null;
|
|
7
|
+
let windowX = null;
|
|
8
|
+
|
|
9
|
+
// Which document started the drag in progress: 'chrome', 'webview', or null
|
|
10
|
+
// when nothing is held. Focus crossing between the host chrome and a
|
|
11
|
+
// <webview> fires blur on whichever side is losing it, and a blur handler
|
|
12
|
+
// that released unconditionally would kill a drag the user is still
|
|
13
|
+
// physically holding. Whoever owns the drag owns its release.
|
|
14
|
+
let dragSource = null;
|
|
15
|
+
|
|
16
|
+
(async () => {
|
|
17
|
+
const pomUrl = 'file://' + path.join(__dirname, '..', 'pom', 'index.js');
|
|
18
|
+
const coordsUrl = 'file://' + path.join(__dirname, '..', 'pom', 'coords.js');
|
|
19
|
+
const [{ createPoms }, coords] = await Promise.all([import(pomUrl), import(coordsUrl)]);
|
|
20
|
+
windowX = coords.windowX;
|
|
21
|
+
|
|
22
|
+
poms = createPoms({
|
|
23
|
+
canvas: document.getElementById('pom-overlay'),
|
|
24
|
+
assetsDir: path.join(__dirname, '..', '..', 'assets'),
|
|
25
|
+
onShake: (x, y, settled) => ipcRenderer.send('pom-shake', { x, y, settled }),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Clicks on the chrome itself — tab strip, toolbar, address bar — spawn too.
|
|
29
|
+
// Page clicks cannot reach here; they arrive over IPC in createTab.
|
|
30
|
+
document.addEventListener('pointerdown', (e) => {
|
|
31
|
+
if (e.button !== undefined && e.button !== 0) return;
|
|
32
|
+
dragSource = 'chrome';
|
|
33
|
+
poms.pointerDown(e.clientX);
|
|
34
|
+
}, { capture: true, passive: true });
|
|
35
|
+
document.addEventListener('pointermove', (e) => {
|
|
36
|
+
// The engine only reads the pointer position while a chrome drag is
|
|
37
|
+
// held, so an unguarded handler would do a call per mouse move for
|
|
38
|
+
// nothing. Mirrors the held check in webview-preload.js.
|
|
39
|
+
if (dragSource !== 'chrome') return;
|
|
40
|
+
poms.pointerMove(e.clientX);
|
|
41
|
+
}, { capture: true, passive: true });
|
|
42
|
+
document.addEventListener('pointerup', (e) => {
|
|
43
|
+
if (e.button !== undefined && e.button !== 0) return;
|
|
44
|
+
dragSource = null;
|
|
45
|
+
poms.pointerUp();
|
|
46
|
+
}, { capture: true, passive: true });
|
|
47
|
+
document.addEventListener('pointercancel', () => {
|
|
48
|
+
dragSource = null;
|
|
49
|
+
poms.pointerUp();
|
|
50
|
+
}, { capture: true, passive: true });
|
|
51
|
+
window.addEventListener('blur', () => {
|
|
52
|
+
// Pressing into a <webview> blurs the host document even though the
|
|
53
|
+
// pointer never left the window. The page's own preload mirrors this
|
|
54
|
+
// handler for real window-level focus loss, so the webview releases
|
|
55
|
+
// its own drags and this one must not.
|
|
56
|
+
if (dragSource === 'webview') return;
|
|
57
|
+
dragSource = null;
|
|
58
|
+
poms.pointerUp();
|
|
59
|
+
}, { passive: true });
|
|
60
|
+
|
|
61
|
+
window.addEventListener('resize', () => poms.resize(), { passive: true });
|
|
62
|
+
})();
|
|
63
|
+
|
|
64
|
+
const WEBVIEW_PRELOAD_URL = 'file://' + path.join(__dirname, '..', 'webview-preload.js');
|
|
65
|
+
const DEFAULT_URL = 'https://www.google.com';
|
|
66
|
+
|
|
67
|
+
const tabsEl = document.getElementById('tabs');
|
|
68
|
+
const contentEl = document.getElementById('content');
|
|
69
|
+
const addressBar = document.getElementById('address-bar');
|
|
70
|
+
|
|
71
|
+
let tabs = [];
|
|
72
|
+
let activeId = null;
|
|
73
|
+
|
|
74
|
+
function normalizeUrl(input) {
|
|
75
|
+
input = (input || '').trim();
|
|
76
|
+
if (!input) return DEFAULT_URL;
|
|
77
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(input)) return input;
|
|
78
|
+
if (/^[\w-]+(\.[\w-]+)+(\/.*)?$/i.test(input) && !input.includes(' ')) {
|
|
79
|
+
return 'https://' + input;
|
|
80
|
+
}
|
|
81
|
+
return 'https://www.google.com/search?q=' + encodeURIComponent(input);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function currentTab() {
|
|
85
|
+
return tabs.find((t) => t.id === activeId);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function createTab(url) {
|
|
89
|
+
const id = 'tab-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7);
|
|
90
|
+
|
|
91
|
+
const webview = document.createElement('webview');
|
|
92
|
+
webview.className = 'browser-webview';
|
|
93
|
+
webview.setAttribute('preload', WEBVIEW_PRELOAD_URL);
|
|
94
|
+
webview.setAttribute('allowpopups', '');
|
|
95
|
+
webview.src = url || DEFAULT_URL;
|
|
96
|
+
|
|
97
|
+
const pane = document.createElement('div');
|
|
98
|
+
pane.className = 'webview-pane';
|
|
99
|
+
pane.appendChild(webview);
|
|
100
|
+
contentEl.appendChild(pane);
|
|
101
|
+
|
|
102
|
+
const titleSpan = document.createElement('span');
|
|
103
|
+
titleSpan.className = 'tab-title';
|
|
104
|
+
titleSpan.textContent = 'New Tab';
|
|
105
|
+
|
|
106
|
+
const closeBtn = document.createElement('span');
|
|
107
|
+
closeBtn.className = 'tab-close';
|
|
108
|
+
closeBtn.textContent = '×';
|
|
109
|
+
closeBtn.addEventListener('click', (e) => {
|
|
110
|
+
e.stopPropagation();
|
|
111
|
+
closeTab(id);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const tabBtn = document.createElement('div');
|
|
115
|
+
tabBtn.className = 'tab';
|
|
116
|
+
tabBtn.appendChild(titleSpan);
|
|
117
|
+
tabBtn.appendChild(closeBtn);
|
|
118
|
+
tabBtn.addEventListener('click', () => activateTab(id));
|
|
119
|
+
tabsEl.appendChild(tabBtn);
|
|
120
|
+
|
|
121
|
+
const tab = { id, webview, pane, tabBtn, titleSpan };
|
|
122
|
+
tabs.push(tab);
|
|
123
|
+
|
|
124
|
+
// pom-move only arrives while a drag is held (see webview-preload.js), so
|
|
125
|
+
// the pane's rect only needs reading once per drag rather than per message.
|
|
126
|
+
let dragRect = null;
|
|
127
|
+
|
|
128
|
+
webview.addEventListener('page-title-updated', (e) => {
|
|
129
|
+
titleSpan.textContent = e.title;
|
|
130
|
+
});
|
|
131
|
+
webview.addEventListener('did-navigate', (e) => {
|
|
132
|
+
if (tab.id === activeId) addressBar.value = e.url;
|
|
133
|
+
});
|
|
134
|
+
webview.addEventListener('did-navigate-in-page', (e) => {
|
|
135
|
+
if (tab.id === activeId) addressBar.value = e.url;
|
|
136
|
+
});
|
|
137
|
+
webview.addEventListener('ipc-message', (e) => {
|
|
138
|
+
if (!poms) return;
|
|
139
|
+
if (e.channel === 'pom-down') {
|
|
140
|
+
const { x = 0 } = e.args[0] || {};
|
|
141
|
+
dragRect = pane.getBoundingClientRect();
|
|
142
|
+
dragSource = 'webview';
|
|
143
|
+
poms.pointerDown(windowX(x, dragRect));
|
|
144
|
+
} else if (e.channel === 'pom-move') {
|
|
145
|
+
const { x = 0 } = e.args[0] || {};
|
|
146
|
+
poms.pointerMove(windowX(x, dragRect));
|
|
147
|
+
} else if (e.channel === 'pom-up') {
|
|
148
|
+
dragRect = null;
|
|
149
|
+
dragSource = null;
|
|
150
|
+
poms.pointerUp();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
activateTab(id);
|
|
155
|
+
return tab;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function activateTab(id) {
|
|
159
|
+
activeId = id;
|
|
160
|
+
tabs.forEach((t) => {
|
|
161
|
+
const active = t.id === id;
|
|
162
|
+
t.pane.classList.toggle('active', active);
|
|
163
|
+
t.tabBtn.classList.toggle('active', active);
|
|
164
|
+
if (active) addressBar.value = t.webview.src;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function closeTab(id) {
|
|
169
|
+
const idx = tabs.findIndex((t) => t.id === id);
|
|
170
|
+
if (idx === -1) return;
|
|
171
|
+
const [tab] = tabs.splice(idx, 1);
|
|
172
|
+
tab.pane.remove();
|
|
173
|
+
tab.tabBtn.remove();
|
|
174
|
+
if (tabs.length === 0) {
|
|
175
|
+
createTab();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (activeId === id) {
|
|
179
|
+
activateTab(tabs[Math.max(0, idx - 1)].id);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function go() {
|
|
184
|
+
const tab = currentTab();
|
|
185
|
+
if (tab) tab.webview.loadURL(normalizeUrl(addressBar.value));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
document.getElementById('new-tab-btn').addEventListener('click', () => createTab());
|
|
189
|
+
document.getElementById('back-btn').addEventListener('click', () => currentTab()?.webview.goBack());
|
|
190
|
+
document.getElementById('fwd-btn').addEventListener('click', () => currentTab()?.webview.goForward());
|
|
191
|
+
document.getElementById('reload-btn').addEventListener('click', () => currentTab()?.webview.reload());
|
|
192
|
+
document.getElementById('go-btn').addEventListener('click', go);
|
|
193
|
+
addressBar.addEventListener('keydown', (e) => {
|
|
194
|
+
if (e.key === 'Enter') go();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
createTab(DEFAULT_URL);
|