@irtio/lobby 0.1.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/LICENSE +21 -0
- package/dist/index.d.ts +90 -0
- package/dist/index.js +722 -0
- package/package.json +25 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 irtio contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<irt-lobby>` — the whole share story in one tag: room code, share link, QR,
|
|
3
|
+
* connection status, an optional name box, and the badge.
|
|
4
|
+
*
|
|
5
|
+
* Two deliberate constraints shape this file. It is a **plain `HTMLElement`** with shadow DOM and
|
|
6
|
+
* inline styles, so a consumer needs no build step, no CSS import and no framework. And it takes
|
|
7
|
+
* its room **structurally** (`AttachableRoom`) rather than importing `@irtio/client`, so the
|
|
8
|
+
* package keeps zero dependencies and a React/Svelte user can drive it with attributes instead.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The part of `@irtio/client`'s `Room` the lobby actually uses. Typed structurally on purpose:
|
|
12
|
+
* `lobby.attach(room)` accepts a real room without this package depending on the client.
|
|
13
|
+
*/
|
|
14
|
+
interface AttachableRoom {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly link: string;
|
|
17
|
+
readonly status: string;
|
|
18
|
+
on(event: 'status', cb: (status: string) => void): () => void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The custom element class. Registered as `irt-lobby` by `defineLobby()`, which this package
|
|
22
|
+
* calls for you on import when a `customElements` registry exists.
|
|
23
|
+
*/
|
|
24
|
+
declare class IrtLobbyElement extends HTMLElement {
|
|
25
|
+
#private;
|
|
26
|
+
static get observedAttributes(): readonly string[];
|
|
27
|
+
constructor();
|
|
28
|
+
/** The room code shown large. Mirrors the `room-code` attribute. */
|
|
29
|
+
get roomCode(): string;
|
|
30
|
+
set roomCode(value: string);
|
|
31
|
+
/** The shareable URL, shown under the code and encoded into the QR. */
|
|
32
|
+
get link(): string;
|
|
33
|
+
set link(value: string);
|
|
34
|
+
/** One of `@irtio/client`'s statuses. Anything else renders as a grey dot with that text. */
|
|
35
|
+
get status(): string;
|
|
36
|
+
set status(value: string);
|
|
37
|
+
/** Whether the name box is shown. Mirrors the `name-entry` boolean attribute. */
|
|
38
|
+
get nameEntry(): boolean;
|
|
39
|
+
set nameEntry(value: boolean);
|
|
40
|
+
connectedCallback(): void;
|
|
41
|
+
attributeChangedCallback(): void;
|
|
42
|
+
/** Unsubscribing here is why `attach` needs no matching `detach` call in app code. */
|
|
43
|
+
disconnectedCallback(): void;
|
|
44
|
+
/**
|
|
45
|
+
* Drives the element from a live room: copies `id`/`link`/`status` in and follows `status`
|
|
46
|
+
* afterwards. Attaching twice replaces the previous subscription. Returns the unsubscribe so
|
|
47
|
+
* a framework can tie it to its own teardown; `disconnectedCallback` calls it regardless.
|
|
48
|
+
*/
|
|
49
|
+
attach(room: AttachableRoom): () => void;
|
|
50
|
+
/** The room passed to the last `attach`, if it is still attached. */
|
|
51
|
+
get room(): AttachableRoom | undefined;
|
|
52
|
+
/** Rebuilds the shadow tree. Cheap enough to do wholesale: this is a handful of nodes. */
|
|
53
|
+
render(): void;
|
|
54
|
+
}
|
|
55
|
+
/** The tag name the element registers under. */
|
|
56
|
+
declare const LOBBY_TAG = "irt-lobby";
|
|
57
|
+
/**
|
|
58
|
+
* Registers `<irt-lobby>`. Idempotent and safe to call anywhere — importing this package already
|
|
59
|
+
* calls it once, and a second registration (HMR, two bundled copies) would otherwise throw.
|
|
60
|
+
*/
|
|
61
|
+
declare function defineLobby(tag?: string): void;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A QR encoder small enough to inline: byte mode, error-correction level L, versions 1–40,
|
|
65
|
+
* automatic version selection and automatic mask selection.
|
|
66
|
+
*
|
|
67
|
+
* It exists because `<irt-lobby>` must be zero-dependency and buildless — a consumer drops the
|
|
68
|
+
* package in and gets a scannable share link with no CDN, no wasm and no CSS file. Only the
|
|
69
|
+
* encoder half of the spec is here: the lobby draws codes, it never reads them.
|
|
70
|
+
*
|
|
71
|
+
* The structure follows ISO/IEC 18004 directly (Nayuki's reference decomposition): encode the
|
|
72
|
+
* payload into a bit string, split it into blocks, append Reed–Solomon parity, interleave, draw
|
|
73
|
+
* the function patterns, zig-zag the codewords in, then pick the mask with the lowest penalty.
|
|
74
|
+
*/
|
|
75
|
+
/** The finished code: `modules[y][x]` is true where the module is dark. */
|
|
76
|
+
interface QrMatrix {
|
|
77
|
+
/** Side length in modules, always `4 * version + 17`. */
|
|
78
|
+
readonly size: number;
|
|
79
|
+
readonly version: number;
|
|
80
|
+
/** The mask pattern (0–7) the encoder chose. */
|
|
81
|
+
readonly mask: number;
|
|
82
|
+
readonly modules: readonly (readonly boolean[])[];
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Encodes `text` as a level-L byte-mode QR code, picking the smallest version that fits and the
|
|
86
|
+
* mask with the lowest penalty. Throws only when the text exceeds version 40.
|
|
87
|
+
*/
|
|
88
|
+
declare function encodeQr(text: string): QrMatrix;
|
|
89
|
+
|
|
90
|
+
export { type AttachableRoom, IrtLobbyElement, LOBBY_TAG, type QrMatrix, defineLobby, encodeQr };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
// src/qr.ts
|
|
2
|
+
var ECC_PER_BLOCK = [
|
|
3
|
+
7,
|
|
4
|
+
10,
|
|
5
|
+
15,
|
|
6
|
+
20,
|
|
7
|
+
26,
|
|
8
|
+
18,
|
|
9
|
+
20,
|
|
10
|
+
24,
|
|
11
|
+
30,
|
|
12
|
+
18,
|
|
13
|
+
20,
|
|
14
|
+
24,
|
|
15
|
+
26,
|
|
16
|
+
30,
|
|
17
|
+
22,
|
|
18
|
+
24,
|
|
19
|
+
28,
|
|
20
|
+
30,
|
|
21
|
+
28,
|
|
22
|
+
28,
|
|
23
|
+
28,
|
|
24
|
+
28,
|
|
25
|
+
30,
|
|
26
|
+
30,
|
|
27
|
+
26,
|
|
28
|
+
28,
|
|
29
|
+
30,
|
|
30
|
+
30,
|
|
31
|
+
30,
|
|
32
|
+
30,
|
|
33
|
+
30,
|
|
34
|
+
30,
|
|
35
|
+
30,
|
|
36
|
+
30,
|
|
37
|
+
30,
|
|
38
|
+
30,
|
|
39
|
+
30,
|
|
40
|
+
30,
|
|
41
|
+
30,
|
|
42
|
+
30
|
|
43
|
+
];
|
|
44
|
+
var BLOCKS = [
|
|
45
|
+
1,
|
|
46
|
+
1,
|
|
47
|
+
1,
|
|
48
|
+
1,
|
|
49
|
+
1,
|
|
50
|
+
2,
|
|
51
|
+
2,
|
|
52
|
+
2,
|
|
53
|
+
2,
|
|
54
|
+
4,
|
|
55
|
+
4,
|
|
56
|
+
4,
|
|
57
|
+
4,
|
|
58
|
+
4,
|
|
59
|
+
6,
|
|
60
|
+
6,
|
|
61
|
+
6,
|
|
62
|
+
6,
|
|
63
|
+
7,
|
|
64
|
+
8,
|
|
65
|
+
8,
|
|
66
|
+
9,
|
|
67
|
+
9,
|
|
68
|
+
10,
|
|
69
|
+
12,
|
|
70
|
+
12,
|
|
71
|
+
12,
|
|
72
|
+
13,
|
|
73
|
+
14,
|
|
74
|
+
15,
|
|
75
|
+
16,
|
|
76
|
+
17,
|
|
77
|
+
18,
|
|
78
|
+
19,
|
|
79
|
+
19,
|
|
80
|
+
20,
|
|
81
|
+
21,
|
|
82
|
+
22,
|
|
83
|
+
24,
|
|
84
|
+
25
|
|
85
|
+
];
|
|
86
|
+
var PENALTY_N1 = 3;
|
|
87
|
+
var PENALTY_N2 = 3;
|
|
88
|
+
var PENALTY_N3 = 40;
|
|
89
|
+
var PENALTY_N4 = 10;
|
|
90
|
+
function rawDataModules(version) {
|
|
91
|
+
let result = (16 * version + 128) * version + 64;
|
|
92
|
+
if (version >= 2) {
|
|
93
|
+
const numAlign = Math.floor(version / 7) + 2;
|
|
94
|
+
result -= (25 * numAlign - 10) * numAlign - 55;
|
|
95
|
+
if (version >= 7) result -= 36;
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
function dataCodewords(version) {
|
|
100
|
+
return Math.floor(rawDataModules(version) / 8) - ECC_PER_BLOCK[version - 1] * BLOCKS[version - 1];
|
|
101
|
+
}
|
|
102
|
+
function alignmentPositions(version) {
|
|
103
|
+
if (version === 1) return [];
|
|
104
|
+
const size = version * 4 + 17;
|
|
105
|
+
const numAlign = Math.floor(version / 7) + 2;
|
|
106
|
+
const step = version === 32 ? 26 : Math.ceil((version * 4 + 4) / (numAlign * 2 - 2)) * 2;
|
|
107
|
+
const result = [6];
|
|
108
|
+
for (let pos = size - 7; result.length < numAlign; pos -= step) result.splice(1, 0, pos);
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
function gfMul(x, y) {
|
|
112
|
+
let z = 0;
|
|
113
|
+
for (let i = 7; i >= 0; i--) {
|
|
114
|
+
z = z << 1 ^ (z >>> 7) * 285;
|
|
115
|
+
z ^= (y >>> i & 1) * x;
|
|
116
|
+
}
|
|
117
|
+
return z & 255;
|
|
118
|
+
}
|
|
119
|
+
function rsDivisor(degree) {
|
|
120
|
+
const result = new Uint8Array(degree);
|
|
121
|
+
result[degree - 1] = 1;
|
|
122
|
+
let root = 1;
|
|
123
|
+
for (let i = 0; i < degree; i++) {
|
|
124
|
+
for (let j = 0; j < degree; j++) {
|
|
125
|
+
result[j] = gfMul(result[j], root);
|
|
126
|
+
if (j + 1 < degree) result[j] = result[j] ^ result[j + 1];
|
|
127
|
+
}
|
|
128
|
+
root = gfMul(root, 2);
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
function rsRemainder(data, divisor) {
|
|
133
|
+
const result = new Uint8Array(divisor.length);
|
|
134
|
+
for (const b of data) {
|
|
135
|
+
const factor = b ^ result[0];
|
|
136
|
+
result.copyWithin(0, 1);
|
|
137
|
+
result[result.length - 1] = 0;
|
|
138
|
+
for (let i = 0; i < result.length; i++) result[i] = result[i] ^ gfMul(divisor[i], factor);
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
function toBytes(text) {
|
|
143
|
+
return new TextEncoder().encode(text);
|
|
144
|
+
}
|
|
145
|
+
function pickVersion(byteCount) {
|
|
146
|
+
for (let version = 1; version <= 40; version++) {
|
|
147
|
+
const countBits = version < 10 ? 8 : 16;
|
|
148
|
+
if (4 + countBits + byteCount * 8 <= dataCodewords(version) * 8) return version;
|
|
149
|
+
}
|
|
150
|
+
throw new RangeError(`irt-lobby: ${byteCount} bytes do not fit in a QR code`);
|
|
151
|
+
}
|
|
152
|
+
function buildCodewords(bytes, version) {
|
|
153
|
+
const bits = [];
|
|
154
|
+
const push = (value, width) => {
|
|
155
|
+
for (let i = width - 1; i >= 0; i--) bits.push(value >>> i & 1);
|
|
156
|
+
};
|
|
157
|
+
push(4, 4);
|
|
158
|
+
push(bytes.length, version < 10 ? 8 : 16);
|
|
159
|
+
for (const b of bytes) push(b, 8);
|
|
160
|
+
const capacityBits = dataCodewords(version) * 8;
|
|
161
|
+
push(0, Math.min(4, capacityBits - bits.length));
|
|
162
|
+
push(0, (8 - bits.length % 8) % 8);
|
|
163
|
+
const codewords = [];
|
|
164
|
+
for (let i = 0; i < bits.length; i += 8) {
|
|
165
|
+
let byte = 0;
|
|
166
|
+
for (let j = 0; j < 8; j++) byte = byte << 1 | bits[i + j];
|
|
167
|
+
codewords.push(byte);
|
|
168
|
+
}
|
|
169
|
+
for (let pad = 236; codewords.length < capacityBits / 8; pad ^= 236 ^ 17) {
|
|
170
|
+
codewords.push(pad);
|
|
171
|
+
}
|
|
172
|
+
return codewords;
|
|
173
|
+
}
|
|
174
|
+
function addEccAndInterleave(data, version) {
|
|
175
|
+
const numBlocks = BLOCKS[version - 1];
|
|
176
|
+
const eccLen = ECC_PER_BLOCK[version - 1];
|
|
177
|
+
const rawCodewords = Math.floor(rawDataModules(version) / 8);
|
|
178
|
+
const numShort = numBlocks - rawCodewords % numBlocks;
|
|
179
|
+
const shortLen = Math.floor(rawCodewords / numBlocks);
|
|
180
|
+
const divisor = rsDivisor(eccLen);
|
|
181
|
+
const blocks = [];
|
|
182
|
+
for (let i = 0, k = 0; i < numBlocks; i++) {
|
|
183
|
+
const len = shortLen - eccLen + (i < numShort ? 0 : 1);
|
|
184
|
+
const dat = data.slice(k, k + len);
|
|
185
|
+
k += len;
|
|
186
|
+
const block = dat.slice();
|
|
187
|
+
if (i < numShort) block.push(0);
|
|
188
|
+
blocks.push(block.concat(Array.from(rsRemainder(dat, divisor))));
|
|
189
|
+
}
|
|
190
|
+
const result = [];
|
|
191
|
+
for (let i = 0; i < blocks[0].length; i++) {
|
|
192
|
+
for (let j = 0; j < blocks.length; j++) {
|
|
193
|
+
if (i !== shortLen - eccLen || j >= numShort) result.push(blocks[j][i]);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
var Grid = class {
|
|
199
|
+
constructor(version) {
|
|
200
|
+
this.version = version;
|
|
201
|
+
this.size = version * 4 + 17;
|
|
202
|
+
this.modules = Array.from(
|
|
203
|
+
{ length: this.size },
|
|
204
|
+
() => new Array(this.size).fill(false)
|
|
205
|
+
);
|
|
206
|
+
this.reserved = Array.from(
|
|
207
|
+
{ length: this.size },
|
|
208
|
+
() => new Array(this.size).fill(false)
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
version;
|
|
212
|
+
size;
|
|
213
|
+
modules;
|
|
214
|
+
reserved;
|
|
215
|
+
/** Function modules are both set and marked, so the data pass and the mask skip them. */
|
|
216
|
+
setFunction(x, y, dark) {
|
|
217
|
+
this.modules[y][x] = dark;
|
|
218
|
+
this.reserved[y][x] = true;
|
|
219
|
+
}
|
|
220
|
+
drawFunctionPatterns() {
|
|
221
|
+
for (let i = 0; i < this.size; i++) {
|
|
222
|
+
this.setFunction(6, i, i % 2 === 0);
|
|
223
|
+
this.setFunction(i, 6, i % 2 === 0);
|
|
224
|
+
}
|
|
225
|
+
this.drawFinder(3, 3);
|
|
226
|
+
this.drawFinder(this.size - 4, 3);
|
|
227
|
+
this.drawFinder(3, this.size - 4);
|
|
228
|
+
const align = alignmentPositions(this.version);
|
|
229
|
+
for (let i = 0; i < align.length; i++) {
|
|
230
|
+
for (let j = 0; j < align.length; j++) {
|
|
231
|
+
const corner = i === 0 && j === 0 || i === 0 && j === align.length - 1 || i === align.length - 1 && j === 0;
|
|
232
|
+
if (!corner) this.drawAlignment(align[i], align[j]);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
this.drawFormatBits(0);
|
|
236
|
+
this.drawVersionBits();
|
|
237
|
+
}
|
|
238
|
+
drawFinder(x, y) {
|
|
239
|
+
for (let dy = -4; dy <= 4; dy++) {
|
|
240
|
+
for (let dx = -4; dx <= 4; dx++) {
|
|
241
|
+
const dist = Math.max(Math.abs(dx), Math.abs(dy));
|
|
242
|
+
const xx = x + dx;
|
|
243
|
+
const yy = y + dy;
|
|
244
|
+
if (xx >= 0 && xx < this.size && yy >= 0 && yy < this.size) {
|
|
245
|
+
this.setFunction(xx, yy, dist !== 2 && dist !== 4);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
drawAlignment(x, y) {
|
|
251
|
+
for (let dy = -2; dy <= 2; dy++) {
|
|
252
|
+
for (let dx = -2; dx <= 2; dx++) {
|
|
253
|
+
this.setFunction(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/** The 15-bit BCH format word (level L = 0b01), written twice as the spec requires. */
|
|
258
|
+
drawFormatBits(mask) {
|
|
259
|
+
const data = 1 << 3 | mask;
|
|
260
|
+
let rem = data;
|
|
261
|
+
for (let i = 0; i < 10; i++) rem = rem << 1 ^ (rem >>> 9) * 1335;
|
|
262
|
+
const bits = (data << 10 | rem) ^ 21522;
|
|
263
|
+
const bit = (i) => (bits >>> i & 1) !== 0;
|
|
264
|
+
for (let i = 0; i <= 5; i++) this.setFunction(8, i, bit(i));
|
|
265
|
+
this.setFunction(8, 7, bit(6));
|
|
266
|
+
this.setFunction(8, 8, bit(7));
|
|
267
|
+
this.setFunction(7, 8, bit(8));
|
|
268
|
+
for (let i = 9; i < 15; i++) this.setFunction(14 - i, 8, bit(i));
|
|
269
|
+
for (let i = 0; i < 8; i++) this.setFunction(this.size - 1 - i, 8, bit(i));
|
|
270
|
+
for (let i = 8; i < 15; i++) this.setFunction(8, this.size - 15 + i, bit(i));
|
|
271
|
+
this.setFunction(8, this.size - 8, true);
|
|
272
|
+
}
|
|
273
|
+
drawVersionBits() {
|
|
274
|
+
if (this.version < 7) return;
|
|
275
|
+
let rem = this.version;
|
|
276
|
+
for (let i = 0; i < 12; i++) rem = rem << 1 ^ (rem >>> 11) * 7973;
|
|
277
|
+
const bits = this.version << 12 | rem;
|
|
278
|
+
for (let i = 0; i < 18; i++) {
|
|
279
|
+
const dark = (bits >>> i & 1) !== 0;
|
|
280
|
+
const a = this.size - 11 + i % 3;
|
|
281
|
+
const b = Math.floor(i / 3);
|
|
282
|
+
this.setFunction(a, b, dark);
|
|
283
|
+
this.setFunction(b, a, dark);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/** Two-module-wide columns, right to left, alternating upward and downward. */
|
|
287
|
+
drawCodewords(data) {
|
|
288
|
+
let i = 0;
|
|
289
|
+
for (let right = this.size - 1; right >= 1; right -= 2) {
|
|
290
|
+
if (right === 6) right = 5;
|
|
291
|
+
for (let vert = 0; vert < this.size; vert++) {
|
|
292
|
+
for (let j = 0; j < 2; j++) {
|
|
293
|
+
const x = right - j;
|
|
294
|
+
const upward = (right + 1 & 2) === 0;
|
|
295
|
+
const y = upward ? this.size - 1 - vert : vert;
|
|
296
|
+
if (!this.reserved[y][x] && i < data.length * 8) {
|
|
297
|
+
this.modules[y][x] = (data[i >>> 3] >>> 7 - (i & 7) & 1) !== 0;
|
|
298
|
+
i++;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/** XORs the mask over the data modules; applying it twice restores the grid. */
|
|
305
|
+
applyMask(mask) {
|
|
306
|
+
for (let y = 0; y < this.size; y++) {
|
|
307
|
+
for (let x = 0; x < this.size; x++) {
|
|
308
|
+
if (this.reserved[y][x]) continue;
|
|
309
|
+
let invert;
|
|
310
|
+
switch (mask) {
|
|
311
|
+
case 0:
|
|
312
|
+
invert = (x + y) % 2 === 0;
|
|
313
|
+
break;
|
|
314
|
+
case 1:
|
|
315
|
+
invert = y % 2 === 0;
|
|
316
|
+
break;
|
|
317
|
+
case 2:
|
|
318
|
+
invert = x % 3 === 0;
|
|
319
|
+
break;
|
|
320
|
+
case 3:
|
|
321
|
+
invert = (x + y) % 3 === 0;
|
|
322
|
+
break;
|
|
323
|
+
case 4:
|
|
324
|
+
invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0;
|
|
325
|
+
break;
|
|
326
|
+
case 5:
|
|
327
|
+
invert = x * y % 2 + x * y % 3 === 0;
|
|
328
|
+
break;
|
|
329
|
+
case 6:
|
|
330
|
+
invert = (x * y % 2 + x * y % 3) % 2 === 0;
|
|
331
|
+
break;
|
|
332
|
+
default:
|
|
333
|
+
invert = ((x + y) % 2 + x * y % 3) % 2 === 0;
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
if (invert) this.modules[y][x] = !this.modules[y][x];
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/** The spec's four penalty rules; the lowest-scoring mask is the one that ships. */
|
|
341
|
+
penalty() {
|
|
342
|
+
let result = 0;
|
|
343
|
+
for (let y = 0; y < this.size; y++) {
|
|
344
|
+
result += this.linePenalty((x) => this.modules[y][x]);
|
|
345
|
+
}
|
|
346
|
+
for (let x = 0; x < this.size; x++) {
|
|
347
|
+
result += this.linePenalty((y) => this.modules[y][x]);
|
|
348
|
+
}
|
|
349
|
+
for (let y = 0; y < this.size - 1; y++) {
|
|
350
|
+
for (let x = 0; x < this.size - 1; x++) {
|
|
351
|
+
const c = this.modules[y][x];
|
|
352
|
+
if (c === this.modules[y][x + 1] && c === this.modules[y + 1][x] && c === this.modules[y + 1][x + 1]) {
|
|
353
|
+
result += PENALTY_N2;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
let dark = 0;
|
|
358
|
+
for (const row of this.modules) for (const m of row) if (m) dark++;
|
|
359
|
+
const total = this.size * this.size;
|
|
360
|
+
result += (Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1) * PENALTY_N4;
|
|
361
|
+
return result;
|
|
362
|
+
}
|
|
363
|
+
/** Rules 1 and 3 for one row or column: long runs, plus 1:1:3:1:1 finder look-alikes. */
|
|
364
|
+
linePenalty(at) {
|
|
365
|
+
let result = 0;
|
|
366
|
+
let runColor = false;
|
|
367
|
+
let runLength = 0;
|
|
368
|
+
const history = [0, 0, 0, 0, 0, 0, 0];
|
|
369
|
+
const addHistory = (length) => {
|
|
370
|
+
if (history[0] === 0) length += this.size;
|
|
371
|
+
history.pop();
|
|
372
|
+
history.unshift(length);
|
|
373
|
+
};
|
|
374
|
+
const countPatterns = () => {
|
|
375
|
+
const n = history[1];
|
|
376
|
+
const core = n > 0 && history[2] === n && history[3] === n * 3 && history[4] === n && history[5] === n;
|
|
377
|
+
return (core && history[0] >= n * 4 && history[6] >= n ? 1 : 0) + (core && history[6] >= n * 4 && history[0] >= n ? 1 : 0);
|
|
378
|
+
};
|
|
379
|
+
for (let i = 0; i < this.size; i++) {
|
|
380
|
+
if (at(i) === runColor) {
|
|
381
|
+
runLength++;
|
|
382
|
+
if (runLength === 5) result += PENALTY_N1;
|
|
383
|
+
else if (runLength > 5) result++;
|
|
384
|
+
} else {
|
|
385
|
+
addHistory(runLength);
|
|
386
|
+
if (!runColor) result += countPatterns() * PENALTY_N3;
|
|
387
|
+
runColor = at(i);
|
|
388
|
+
runLength = 1;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (runColor) {
|
|
392
|
+
addHistory(runLength);
|
|
393
|
+
runLength = 0;
|
|
394
|
+
}
|
|
395
|
+
addHistory(runLength + this.size);
|
|
396
|
+
return result + countPatterns() * PENALTY_N3;
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
function encodeQr(text) {
|
|
400
|
+
const bytes = toBytes(text);
|
|
401
|
+
const version = pickVersion(bytes.length);
|
|
402
|
+
const codewords = addEccAndInterleave(buildCodewords(bytes, version), version);
|
|
403
|
+
const grid = new Grid(version);
|
|
404
|
+
grid.drawFunctionPatterns();
|
|
405
|
+
grid.drawCodewords(codewords);
|
|
406
|
+
let bestMask = 0;
|
|
407
|
+
let bestPenalty = Number.POSITIVE_INFINITY;
|
|
408
|
+
for (let mask = 0; mask < 8; mask++) {
|
|
409
|
+
grid.applyMask(mask);
|
|
410
|
+
grid.drawFormatBits(mask);
|
|
411
|
+
const penalty = grid.penalty();
|
|
412
|
+
if (penalty < bestPenalty) {
|
|
413
|
+
bestPenalty = penalty;
|
|
414
|
+
bestMask = mask;
|
|
415
|
+
}
|
|
416
|
+
grid.applyMask(mask);
|
|
417
|
+
}
|
|
418
|
+
grid.applyMask(bestMask);
|
|
419
|
+
grid.drawFormatBits(bestMask);
|
|
420
|
+
return { size: grid.size, version, mask: bestMask, modules: grid.modules };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// src/element.ts
|
|
424
|
+
var STATUS_LABELS = {
|
|
425
|
+
connecting: "connecting",
|
|
426
|
+
starting: "waking the server",
|
|
427
|
+
connected: "connected",
|
|
428
|
+
reconnecting: "reconnecting",
|
|
429
|
+
closed: "disconnected"
|
|
430
|
+
};
|
|
431
|
+
var STATUS_COLORS = {
|
|
432
|
+
connecting: "#e0a33a",
|
|
433
|
+
starting: "#e0a33a",
|
|
434
|
+
connected: "#3ac177",
|
|
435
|
+
reconnecting: "#e0a33a",
|
|
436
|
+
closed: "#d1495b"
|
|
437
|
+
};
|
|
438
|
+
var OBSERVED = ["room-code", "link", "status", "name-entry"];
|
|
439
|
+
var STYLE = `
|
|
440
|
+
:host {
|
|
441
|
+
display: block;
|
|
442
|
+
box-sizing: border-box;
|
|
443
|
+
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
444
|
+
color: #f2f3f5;
|
|
445
|
+
background: #16181d;
|
|
446
|
+
border: 1px solid #2a2e37;
|
|
447
|
+
border-radius: 12px;
|
|
448
|
+
padding: 16px;
|
|
449
|
+
max-width: 320px;
|
|
450
|
+
}
|
|
451
|
+
:host([hidden]) { display: none; }
|
|
452
|
+
* { box-sizing: border-box; }
|
|
453
|
+
.status { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #9aa1ad; }
|
|
454
|
+
.dot { width: 8px; height: 8px; border-radius: 50%; background: #9aa1ad; flex: none; }
|
|
455
|
+
.code {
|
|
456
|
+
display: block; width: 100%; margin: 10px 0 4px;
|
|
457
|
+
font: 700 40px/1.1 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
458
|
+
letter-spacing: 0.12em; text-align: center;
|
|
459
|
+
color: inherit; background: none; border: 0; padding: 8px 0; cursor: pointer;
|
|
460
|
+
border-radius: 8px;
|
|
461
|
+
}
|
|
462
|
+
.code:hover { background: #1e2129; }
|
|
463
|
+
.link {
|
|
464
|
+
display: block; width: 100%;
|
|
465
|
+
font: 400 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
466
|
+
color: #9aa1ad; background: none; border: 0; padding: 4px; cursor: pointer;
|
|
467
|
+
overflow-wrap: anywhere; text-align: center; border-radius: 6px;
|
|
468
|
+
}
|
|
469
|
+
.link:hover { background: #1e2129; color: #f2f3f5; }
|
|
470
|
+
canvas { display: block; margin: 12px auto 0; image-rendering: pixelated; border-radius: 4px; }
|
|
471
|
+
form { display: flex; gap: 6px; margin-top: 12px; }
|
|
472
|
+
input {
|
|
473
|
+
flex: 1 1 auto; min-width: 0; padding: 7px 9px; border-radius: 7px;
|
|
474
|
+
border: 1px solid #2a2e37; background: #101216; color: inherit; font: inherit; font-size: 13px;
|
|
475
|
+
}
|
|
476
|
+
button[type='submit'] {
|
|
477
|
+
padding: 7px 12px; border-radius: 7px; border: 0; cursor: pointer;
|
|
478
|
+
background: #f2f3f5; color: #16181d; font: inherit; font-size: 13px; font-weight: 600;
|
|
479
|
+
}
|
|
480
|
+
.badge { margin-top: 12px; font-size: 11px; color: #6c7280; text-align: center; }
|
|
481
|
+
.badge a { color: inherit; }
|
|
482
|
+
.toast { min-height: 14px; font-size: 11px; color: #3ac177; text-align: center; }
|
|
483
|
+
`;
|
|
484
|
+
var IrtLobbyElement = class extends HTMLElement {
|
|
485
|
+
static get observedAttributes() {
|
|
486
|
+
return OBSERVED;
|
|
487
|
+
}
|
|
488
|
+
#root;
|
|
489
|
+
#detach;
|
|
490
|
+
/** Set by `attach`; re-read on every render so a reconnect that changes the code is picked up. */
|
|
491
|
+
#room;
|
|
492
|
+
#toastTimer;
|
|
493
|
+
constructor() {
|
|
494
|
+
super();
|
|
495
|
+
this.#root = this.attachShadow({ mode: "open" });
|
|
496
|
+
}
|
|
497
|
+
// -- attribute-driven properties (the framework path) ----------------------
|
|
498
|
+
/** The room code shown large. Mirrors the `room-code` attribute. */
|
|
499
|
+
get roomCode() {
|
|
500
|
+
return this.getAttribute("room-code") ?? "";
|
|
501
|
+
}
|
|
502
|
+
set roomCode(value) {
|
|
503
|
+
this.setAttribute("room-code", value);
|
|
504
|
+
}
|
|
505
|
+
/** The shareable URL, shown under the code and encoded into the QR. */
|
|
506
|
+
get link() {
|
|
507
|
+
return this.getAttribute("link") ?? "";
|
|
508
|
+
}
|
|
509
|
+
set link(value) {
|
|
510
|
+
this.setAttribute("link", value);
|
|
511
|
+
}
|
|
512
|
+
/** One of `@irtio/client`'s statuses. Anything else renders as a grey dot with that text. */
|
|
513
|
+
get status() {
|
|
514
|
+
return this.getAttribute("status") ?? "connecting";
|
|
515
|
+
}
|
|
516
|
+
set status(value) {
|
|
517
|
+
this.setAttribute("status", value);
|
|
518
|
+
}
|
|
519
|
+
/** Whether the name box is shown. Mirrors the `name-entry` boolean attribute. */
|
|
520
|
+
get nameEntry() {
|
|
521
|
+
return this.hasAttribute("name-entry");
|
|
522
|
+
}
|
|
523
|
+
set nameEntry(value) {
|
|
524
|
+
if (value) this.setAttribute("name-entry", "");
|
|
525
|
+
else this.removeAttribute("name-entry");
|
|
526
|
+
}
|
|
527
|
+
// -- lifecycle -------------------------------------------------------------
|
|
528
|
+
connectedCallback() {
|
|
529
|
+
this.render();
|
|
530
|
+
}
|
|
531
|
+
attributeChangedCallback() {
|
|
532
|
+
if (this.#root.childElementCount > 0) this.render();
|
|
533
|
+
}
|
|
534
|
+
/** Unsubscribing here is why `attach` needs no matching `detach` call in app code. */
|
|
535
|
+
disconnectedCallback() {
|
|
536
|
+
this.#detach?.();
|
|
537
|
+
this.#detach = void 0;
|
|
538
|
+
if (this.#toastTimer !== void 0) clearTimeout(this.#toastTimer);
|
|
539
|
+
}
|
|
540
|
+
// -- the room path ---------------------------------------------------------
|
|
541
|
+
/**
|
|
542
|
+
* Drives the element from a live room: copies `id`/`link`/`status` in and follows `status`
|
|
543
|
+
* afterwards. Attaching twice replaces the previous subscription. Returns the unsubscribe so
|
|
544
|
+
* a framework can tie it to its own teardown; `disconnectedCallback` calls it regardless.
|
|
545
|
+
*/
|
|
546
|
+
attach(room) {
|
|
547
|
+
this.#detach?.();
|
|
548
|
+
this.#room = room;
|
|
549
|
+
this.roomCode = room.id;
|
|
550
|
+
this.link = room.link;
|
|
551
|
+
this.status = room.status;
|
|
552
|
+
const off = room.on("status", (status) => {
|
|
553
|
+
this.roomCode = room.id;
|
|
554
|
+
this.link = room.link;
|
|
555
|
+
this.status = status;
|
|
556
|
+
});
|
|
557
|
+
const detach = () => {
|
|
558
|
+
off();
|
|
559
|
+
if (this.#detach === detach) {
|
|
560
|
+
this.#detach = void 0;
|
|
561
|
+
this.#room = void 0;
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
this.#detach = detach;
|
|
565
|
+
this.render();
|
|
566
|
+
return detach;
|
|
567
|
+
}
|
|
568
|
+
/** The room passed to the last `attach`, if it is still attached. */
|
|
569
|
+
get room() {
|
|
570
|
+
return this.#room;
|
|
571
|
+
}
|
|
572
|
+
// -- rendering -------------------------------------------------------------
|
|
573
|
+
/** Rebuilds the shadow tree. Cheap enough to do wholesale: this is a handful of nodes. */
|
|
574
|
+
render() {
|
|
575
|
+
const doc = this.ownerDocument;
|
|
576
|
+
this.#root.textContent = "";
|
|
577
|
+
const style = doc.createElement("style");
|
|
578
|
+
style.textContent = STYLE;
|
|
579
|
+
this.#root.append(style);
|
|
580
|
+
const status = this.status;
|
|
581
|
+
const statusRow = doc.createElement("div");
|
|
582
|
+
statusRow.className = "status";
|
|
583
|
+
statusRow.setAttribute("part", "status");
|
|
584
|
+
const dot = doc.createElement("span");
|
|
585
|
+
dot.className = "dot";
|
|
586
|
+
dot.style.background = STATUS_COLORS[status] ?? "#9aa1ad";
|
|
587
|
+
const statusText = doc.createElement("span");
|
|
588
|
+
statusText.textContent = STATUS_LABELS[status] ?? status;
|
|
589
|
+
statusRow.append(dot, statusText);
|
|
590
|
+
this.#root.append(statusRow);
|
|
591
|
+
const code = doc.createElement("button");
|
|
592
|
+
code.type = "button";
|
|
593
|
+
code.className = "code";
|
|
594
|
+
code.setAttribute("part", "code");
|
|
595
|
+
code.textContent = this.roomCode || "\xB7\xB7\xB7\xB7";
|
|
596
|
+
code.title = "copy the room code";
|
|
597
|
+
code.addEventListener("click", () => {
|
|
598
|
+
void this.#copy(this.roomCode, "room code copied");
|
|
599
|
+
});
|
|
600
|
+
this.#root.append(code);
|
|
601
|
+
const link = doc.createElement("button");
|
|
602
|
+
link.type = "button";
|
|
603
|
+
link.className = "link";
|
|
604
|
+
link.setAttribute("part", "link");
|
|
605
|
+
link.textContent = this.link || "waiting for a room\u2026";
|
|
606
|
+
link.title = "copy the share link";
|
|
607
|
+
link.addEventListener("click", () => {
|
|
608
|
+
void this.#copy(this.link, "link copied");
|
|
609
|
+
});
|
|
610
|
+
this.#root.append(link);
|
|
611
|
+
const toast = doc.createElement("div");
|
|
612
|
+
toast.className = "toast";
|
|
613
|
+
this.#root.append(toast);
|
|
614
|
+
const canvas = doc.createElement("canvas");
|
|
615
|
+
canvas.setAttribute("part", "qr");
|
|
616
|
+
this.#root.append(canvas);
|
|
617
|
+
if (this.link) this.#drawQr(canvas, this.link);
|
|
618
|
+
else canvas.hidden = true;
|
|
619
|
+
if (this.nameEntry) this.#root.append(this.#nameForm(doc));
|
|
620
|
+
const badge = doc.createElement("div");
|
|
621
|
+
badge.className = "badge";
|
|
622
|
+
badge.setAttribute("part", "badge");
|
|
623
|
+
const anchor = doc.createElement("a");
|
|
624
|
+
anchor.href = "https://irt.io";
|
|
625
|
+
anchor.target = "_blank";
|
|
626
|
+
anchor.rel = "noreferrer";
|
|
627
|
+
anchor.textContent = "multiplayer by irt.io";
|
|
628
|
+
badge.append(anchor);
|
|
629
|
+
this.#root.append(badge);
|
|
630
|
+
}
|
|
631
|
+
/** The optional name box. Submitting emits `name` **before** the app joins, which is the point. */
|
|
632
|
+
#nameForm(doc) {
|
|
633
|
+
const form = doc.createElement("form");
|
|
634
|
+
const input = doc.createElement("input");
|
|
635
|
+
input.type = "text";
|
|
636
|
+
input.name = "name";
|
|
637
|
+
input.placeholder = "your name";
|
|
638
|
+
input.maxLength = 24;
|
|
639
|
+
const submit = doc.createElement("button");
|
|
640
|
+
submit.type = "submit";
|
|
641
|
+
submit.textContent = "join";
|
|
642
|
+
form.append(input, submit);
|
|
643
|
+
form.addEventListener("submit", (event) => {
|
|
644
|
+
event.preventDefault();
|
|
645
|
+
const name = input.value.trim();
|
|
646
|
+
if (name === "") return;
|
|
647
|
+
this.dispatchEvent(
|
|
648
|
+
new CustomEvent("name", { detail: { name }, bubbles: true, composed: true })
|
|
649
|
+
);
|
|
650
|
+
});
|
|
651
|
+
return form;
|
|
652
|
+
}
|
|
653
|
+
/** `navigator.clipboard` is absent on http:// origins and in jsdom; failing is not an error. */
|
|
654
|
+
async #copy(text, message) {
|
|
655
|
+
if (text === "") return;
|
|
656
|
+
const clipboard = globalThis.navigator?.clipboard;
|
|
657
|
+
const writeText = clipboard?.writeText;
|
|
658
|
+
if (typeof writeText !== "function") return;
|
|
659
|
+
try {
|
|
660
|
+
await writeText.call(clipboard, text);
|
|
661
|
+
this.#toast(message);
|
|
662
|
+
} catch {
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
#toast(message) {
|
|
666
|
+
const toast = this.#root.querySelector(".toast");
|
|
667
|
+
if (!toast) return;
|
|
668
|
+
toast.textContent = message;
|
|
669
|
+
if (this.#toastTimer !== void 0) clearTimeout(this.#toastTimer);
|
|
670
|
+
this.#toastTimer = setTimeout(() => {
|
|
671
|
+
toast.textContent = "";
|
|
672
|
+
}, 1500);
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Draws the QR at whole-module scale so it stays crisp. jsdom (and any canvas-less environment)
|
|
676
|
+
* returns no 2D context; the element must still render everything else, so this bails quietly.
|
|
677
|
+
*/
|
|
678
|
+
#drawQr(canvas, text) {
|
|
679
|
+
let qr;
|
|
680
|
+
try {
|
|
681
|
+
qr = encodeQr(text);
|
|
682
|
+
} catch {
|
|
683
|
+
canvas.hidden = true;
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
const quiet = 2;
|
|
687
|
+
const scale = 4;
|
|
688
|
+
const side = (qr.size + quiet * 2) * scale;
|
|
689
|
+
canvas.width = side;
|
|
690
|
+
canvas.height = side;
|
|
691
|
+
canvas.style.width = `${side / 2}px`;
|
|
692
|
+
canvas.style.height = `${side / 2}px`;
|
|
693
|
+
canvas.setAttribute("role", "img");
|
|
694
|
+
canvas.setAttribute("aria-label", `QR code for ${text}`);
|
|
695
|
+
const ctx = canvas.getContext?.("2d");
|
|
696
|
+
if (!ctx) return;
|
|
697
|
+
ctx.fillStyle = "#ffffff";
|
|
698
|
+
ctx.fillRect(0, 0, side, side);
|
|
699
|
+
ctx.fillStyle = "#000000";
|
|
700
|
+
for (let y = 0; y < qr.size; y++) {
|
|
701
|
+
const row = qr.modules[y];
|
|
702
|
+
for (let x = 0; x < qr.size; x++) {
|
|
703
|
+
if (row[x]) ctx.fillRect((x + quiet) * scale, (y + quiet) * scale, scale, scale);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
var LOBBY_TAG = "irt-lobby";
|
|
709
|
+
function defineLobby(tag = LOBBY_TAG) {
|
|
710
|
+
const registry = globalThis.customElements;
|
|
711
|
+
if (!registry || registry.get(tag)) return;
|
|
712
|
+
registry.define(tag, IrtLobbyElement);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// src/index.ts
|
|
716
|
+
defineLobby();
|
|
717
|
+
export {
|
|
718
|
+
IrtLobbyElement,
|
|
719
|
+
LOBBY_TAG,
|
|
720
|
+
defineLobby,
|
|
721
|
+
encodeQr
|
|
722
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@irtio/lobby",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "<irt-lobby>: room code, share link, QR, connection status, name entry",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsup",
|
|
23
|
+
"test": "vitest run"
|
|
24
|
+
}
|
|
25
|
+
}
|