castle-web-sdk 0.4.12 → 0.4.14
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 +35 -8
- package/dist/castle.d.ts +1 -1
- package/dist/commands.d.ts +18 -0
- package/dist/leaderboard.d.ts +6 -0
- package/dist/leaderboard.js +56 -11
- package/dist/runtime.js +155 -4
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -95,7 +95,8 @@ Removes a shared value.
|
|
|
95
95
|
|
|
96
96
|
`Leaderboard` ranks players by a numeric score, per deck and per
|
|
97
97
|
variable name. Pick a variable name for each leaderboard the deck has
|
|
98
|
-
(e.g. `'score'`, `'time'`).
|
|
98
|
+
(e.g. `'score'`, `'time'`). `show` opens Castle's own leaderboard screen;
|
|
99
|
+
reach for `fetch` only if the deck draws the scores itself.
|
|
99
100
|
|
|
100
101
|
### `Leaderboard.write(variable, score, options?)`
|
|
101
102
|
|
|
@@ -116,16 +117,42 @@ const { daysSinceCastleEpoch } = await Time.getServerDate();
|
|
|
116
117
|
Leaderboard.write("score", 1200, { scope: `daily-${daysSinceCastleEpoch}` });
|
|
117
118
|
```
|
|
118
119
|
|
|
120
|
+
### `Leaderboard.show(variable, type, options?): Promise<LeaderboardShowResult>`
|
|
121
|
+
|
|
122
|
+
Opens Castle's own leaderboard screen for `variable` — the same board
|
|
123
|
+
players see on other Castle decks, with FOR YOU and GLOBAL tabs, sharing,
|
|
124
|
+
and profile links. This is the normal way to show a leaderboard: Castle
|
|
125
|
+
draws it and owns the data, so the deck gets no entries back.
|
|
126
|
+
|
|
127
|
+
`type` is `'high'` (highest first) or `'low'` (lowest first). `options` is
|
|
128
|
+
`{ scope?, label? }` — `scope` works as it does in `write`, and `label`
|
|
129
|
+
titles the column of scores (default `'Score'`; e.g. `'Time'`, `'Coins'`).
|
|
130
|
+
|
|
131
|
+
Opening the board also submits the score the player has written this
|
|
132
|
+
session, so their own result is on it right away. The board appears
|
|
133
|
+
immediately, on a loading spinner; the promise resolves once the scores
|
|
134
|
+
are in. The player closes it themselves — there's no `hide`.
|
|
135
|
+
|
|
136
|
+
`LeaderboardShowResult` has a `status`:
|
|
137
|
+
|
|
138
|
+
- `'shown'` — Castle opened the leaderboard.
|
|
139
|
+
- `'unavailable'` — this host has no leaderboard screen, or can't show
|
|
140
|
+
one right now. Nothing was submitted; the score still saves normally.
|
|
141
|
+
|
|
142
|
+
```js
|
|
143
|
+
Leaderboard.write("score", finalScore);
|
|
144
|
+
gameOverButton.onclick = () => Leaderboard.show("score", "high");
|
|
145
|
+
```
|
|
146
|
+
|
|
119
147
|
### `Leaderboard.fetch(variable, type, options?): Promise<LeaderboardData>`
|
|
120
148
|
|
|
121
|
-
Fetches the leaderboard
|
|
122
|
-
|
|
123
|
-
`
|
|
149
|
+
Fetches the leaderboard's entries so the deck can draw them itself — use
|
|
150
|
+
this only when Castle's own screen isn't what you want. `type` and
|
|
151
|
+
`options.scope` work as they do in `show`.
|
|
124
152
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
appear on their own normal timing.
|
|
153
|
+
Like `show`, a fetch reflects **the player's own** score written this
|
|
154
|
+
session right away. Other players' recent scores still appear on their own
|
|
155
|
+
normal timing.
|
|
129
156
|
|
|
130
157
|
The returned `LeaderboardData` has:
|
|
131
158
|
|
package/dist/castle.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export { deckPrefixOf, IMPORTS_DIR, IMPORTS_PREFIX, isImportedFile, resolveDeckF
|
|
|
4
4
|
export { Haptics } from "./haptics";
|
|
5
5
|
export type { CastleHapticsApi, HapticsResult, HapticsStatus, HapticStyle } from "./haptics";
|
|
6
6
|
export { Leaderboard } from "./leaderboard";
|
|
7
|
-
export type { LeaderboardData, LeaderboardEntry, LeaderboardOptions, LeaderboardScope, LeaderboardSort, } from "./leaderboard";
|
|
7
|
+
export type { LeaderboardData, LeaderboardEntry, LeaderboardOptions, LeaderboardScope, LeaderboardShowOptions, LeaderboardShowResult, LeaderboardShowStatus, LeaderboardSort, } from "./leaderboard";
|
|
8
8
|
export { Lifecycle } from "./lifecycle";
|
|
9
9
|
export type { CastleLifecycleApi } from "./lifecycle";
|
|
10
10
|
export { Pass } from "./passes";
|
package/dist/commands.d.ts
CHANGED
|
@@ -20,6 +20,16 @@ export interface RawLeaderboard {
|
|
|
20
20
|
score?: string | number | null;
|
|
21
21
|
} | null;
|
|
22
22
|
}
|
|
23
|
+
export type LeaderboardShowStatus = "shown" | "unavailable";
|
|
24
|
+
export interface LeaderboardShowResult {
|
|
25
|
+
status: LeaderboardShowStatus;
|
|
26
|
+
}
|
|
27
|
+
export interface LeaderboardStreakCredit {
|
|
28
|
+
deckId: string | null;
|
|
29
|
+
kind: string;
|
|
30
|
+
priorStreak: number;
|
|
31
|
+
newStreak: number;
|
|
32
|
+
}
|
|
23
33
|
export type PassOfferStatus = "purchased" | "alreadyOwned" | "cancelled" | "unavailable";
|
|
24
34
|
export interface PassOfferResult {
|
|
25
35
|
status: PassOfferStatus;
|
|
@@ -62,6 +72,13 @@ export interface CommandParams {
|
|
|
62
72
|
score: number;
|
|
63
73
|
scope?: string | null;
|
|
64
74
|
};
|
|
75
|
+
"leaderboard.show": {
|
|
76
|
+
variable: string;
|
|
77
|
+
type: "high" | "low";
|
|
78
|
+
scope?: string | null;
|
|
79
|
+
label?: string | null;
|
|
80
|
+
score?: number | null;
|
|
81
|
+
};
|
|
65
82
|
"user.getCurrent": Record<string, never>;
|
|
66
83
|
"time.getServerTime": Record<string, never>;
|
|
67
84
|
"pass.has": {
|
|
@@ -100,6 +117,7 @@ export interface CommandResult {
|
|
|
100
117
|
"leaderboard.save": {
|
|
101
118
|
ok: true;
|
|
102
119
|
};
|
|
120
|
+
"leaderboard.show": LeaderboardShowResult;
|
|
103
121
|
"user.getCurrent": {
|
|
104
122
|
user: {
|
|
105
123
|
userId: string;
|
package/dist/leaderboard.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { type LeaderboardShowResult } from "./commands";
|
|
2
|
+
export type { LeaderboardShowResult, LeaderboardShowStatus } from "./commands";
|
|
1
3
|
export type LeaderboardSort = "high" | "low";
|
|
2
4
|
export type LeaderboardScope = string;
|
|
3
5
|
export interface LeaderboardOptions {
|
|
4
6
|
scope?: LeaderboardScope | null;
|
|
5
7
|
}
|
|
8
|
+
export interface LeaderboardShowOptions extends LeaderboardOptions {
|
|
9
|
+
label?: string | null;
|
|
10
|
+
}
|
|
6
11
|
export interface LeaderboardEntry {
|
|
7
12
|
place: number;
|
|
8
13
|
value: number;
|
|
@@ -17,4 +22,5 @@ export interface LeaderboardData {
|
|
|
17
22
|
export declare const Leaderboard: {
|
|
18
23
|
readonly write: (variable: string, score: number, options?: LeaderboardOptions) => void;
|
|
19
24
|
readonly fetch: (variable: string, type: LeaderboardSort, options?: LeaderboardOptions) => Promise<LeaderboardData>;
|
|
25
|
+
readonly show: (variable: string, type: LeaderboardSort, options?: LeaderboardShowOptions) => Promise<LeaderboardShowResult>;
|
|
20
26
|
};
|
package/dist/leaderboard.js
CHANGED
|
@@ -15,6 +15,9 @@ export const Leaderboard = {
|
|
|
15
15
|
fetch(variable, type, options = {}) {
|
|
16
16
|
return fetchLeaderboardData(variable, type, options);
|
|
17
17
|
},
|
|
18
|
+
show(variable, type, options = {}) {
|
|
19
|
+
return showLeaderboard(variable, type, options);
|
|
20
|
+
},
|
|
18
21
|
};
|
|
19
22
|
function writeLeaderboard(variable, score, options) {
|
|
20
23
|
if (isEdit())
|
|
@@ -30,17 +33,8 @@ async function fetchLeaderboardData(variable, type, options) {
|
|
|
30
33
|
assertLeaderboardVariable(variable, "Leaderboard.fetch");
|
|
31
34
|
assertLeaderboardType(type, "Leaderboard.fetch");
|
|
32
35
|
const scope = leaderboardScope(options);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
// score shows up immediately (mirrors getLeaderboard in
|
|
36
|
-
// core/src/leaderboards.cpp — presence of a buffered score, not dirtiness,
|
|
37
|
-
// gates the write-through). Otherwise a plain read of the settled board.
|
|
38
|
-
const record = leaderboardWrites.get(leaderboardWriteKey(variable, scope));
|
|
39
|
-
const score = record
|
|
40
|
-
? type === "high"
|
|
41
|
-
? record.highScore
|
|
42
|
-
: record.lowScore
|
|
43
|
-
: null;
|
|
36
|
+
const record = pendingLeaderboardWrite(variable, scope);
|
|
37
|
+
const score = bufferedScore(record, type);
|
|
44
38
|
const { leaderboard, currentUserId } = await hostRequest("leaderboard.fetch", {
|
|
45
39
|
variable,
|
|
46
40
|
type,
|
|
@@ -52,6 +46,57 @@ async function fetchLeaderboardData(variable, type, options) {
|
|
|
52
46
|
}
|
|
53
47
|
return normalizeLeaderboard(leaderboard, currentUserId);
|
|
54
48
|
}
|
|
49
|
+
// Opening the host's leaderboard UI takes the same write-through shortcut a
|
|
50
|
+
// fetch does: the buffered score rides along so the player's own just-played
|
|
51
|
+
// score is on the board immediately, matching what opening the classic
|
|
52
|
+
// leaderboard does. The panel and its data stay host-side — a deck that calls
|
|
53
|
+
// this receives no leaderboard rows, only whether it was presented.
|
|
54
|
+
async function showLeaderboard(variable, type, options) {
|
|
55
|
+
assertLeaderboardVariable(variable, "Leaderboard.show");
|
|
56
|
+
assertLeaderboardType(type, "Leaderboard.show");
|
|
57
|
+
const scope = leaderboardScope(options);
|
|
58
|
+
const record = pendingLeaderboardWrite(variable, scope);
|
|
59
|
+
const score = bufferedScore(record, type);
|
|
60
|
+
const label = typeof options.label === "string" ? options.label : null;
|
|
61
|
+
let result;
|
|
62
|
+
try {
|
|
63
|
+
result = await hostRequest("leaderboard.show", {
|
|
64
|
+
variable,
|
|
65
|
+
type,
|
|
66
|
+
scope,
|
|
67
|
+
...(label === null ? {} : { label }),
|
|
68
|
+
...(score === null ? {} : { score }),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
// An app build older than this command rejects it outright; that is
|
|
73
|
+
// capability divergence, not a failure the deck should have to handle.
|
|
74
|
+
if (error instanceof CastleError && error.code === "UNKNOWN_COMMAND") {
|
|
75
|
+
return { status: "unavailable" };
|
|
76
|
+
}
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
// Only a host that actually presented the board wrote the score, so only
|
|
80
|
+
// then is the buffered write settled; otherwise the periodic flush delivers
|
|
81
|
+
// it and a duplicate write (double streak credit) is avoided either way.
|
|
82
|
+
if (result.status === "shown" && record && score !== null) {
|
|
83
|
+
clearLeaderboardDirtyAfterFetch(record, type, score);
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
function pendingLeaderboardWrite(variable, scope) {
|
|
88
|
+
return leaderboardWrites.get(leaderboardWriteKey(variable, scope));
|
|
89
|
+
}
|
|
90
|
+
// The value to send along with a read: if the deck has written a score for this
|
|
91
|
+
// variable+scope this session, the host writes-and-reads via leaderboardV2 so
|
|
92
|
+
// the player's own score shows up immediately. Mirrors getLeaderboard in
|
|
93
|
+
// core/src/leaderboards.cpp — presence of a buffered score, not dirtiness,
|
|
94
|
+
// gates the write-through. No buffered score → a plain read of the settled board.
|
|
95
|
+
function bufferedScore(record, type) {
|
|
96
|
+
if (!record)
|
|
97
|
+
return null;
|
|
98
|
+
return type === "high" ? record.highScore : record.lowScore;
|
|
99
|
+
}
|
|
55
100
|
// After a write-through fetch, clear the dirty flag for the side we just sent
|
|
56
101
|
// (so the periodic flush won't re-send it via saveVariableToLeaderboard). If
|
|
57
102
|
// the other side's buffered value matches what we sent (the common single-score
|
package/dist/runtime.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { CASTLE_SDK_PROTOCOL, } from "./commands";
|
|
2
2
|
import { getCastleEmbed, isEdit } from "./context";
|
|
3
3
|
export const CARD_RATIO = 5 / 7;
|
|
4
|
+
// Host -> deck capture request, and the deck's answer. Kept in sync with the
|
|
5
|
+
// editor shell (cli/src/shell/coverApi.ts).
|
|
6
|
+
const HOST_CAPTURE_REQUEST = "castle-capture-cover";
|
|
7
|
+
const HOST_CAPTURE_RESULT = "castle-capture-cover-result";
|
|
4
8
|
let ws = null;
|
|
5
9
|
let logBuffer = [];
|
|
6
10
|
let nextRequestId = 1;
|
|
@@ -21,6 +25,8 @@ const dynamicImport = new Function("u", "return import(u)");
|
|
|
21
25
|
export function setup() {
|
|
22
26
|
interceptConsole();
|
|
23
27
|
connectLocal();
|
|
28
|
+
initHostCapture();
|
|
29
|
+
initPlaySelection();
|
|
24
30
|
initPlayCard();
|
|
25
31
|
}
|
|
26
32
|
export function writeFile(path, contents) {
|
|
@@ -64,6 +70,35 @@ export function initCard() {
|
|
|
64
70
|
window.addEventListener("resize", resize);
|
|
65
71
|
return card;
|
|
66
72
|
}
|
|
73
|
+
// A deck being PLAYED is a game, not a document: dragging across it should move
|
|
74
|
+
// a paddle, not blue-highlight the score, and a long press on a phone should not
|
|
75
|
+
// raise the copy/define callout over the card.
|
|
76
|
+
//
|
|
77
|
+
// It belongs here rather than in a host. Selection is decided inside the deck's
|
|
78
|
+
// own document, so neither the website's iframe nor the app's WebView can reach
|
|
79
|
+
// it from outside -- and doing it once in the SDK covers the play panel,
|
|
80
|
+
// castle.xyz and the feed together.
|
|
81
|
+
//
|
|
82
|
+
// Play mode only: in edit mode the same document hosts kit editors and deck UI,
|
|
83
|
+
// where selecting text is the point. Form fields keep selection either way, so a
|
|
84
|
+
// deck that asks for a name still works.
|
|
85
|
+
function initPlaySelection() {
|
|
86
|
+
if (isEdit())
|
|
87
|
+
return;
|
|
88
|
+
const style = document.createElement("style");
|
|
89
|
+
style.textContent = `
|
|
90
|
+
html, body {
|
|
91
|
+
-webkit-user-select: none;
|
|
92
|
+
user-select: none;
|
|
93
|
+
-webkit-touch-callout: none;
|
|
94
|
+
}
|
|
95
|
+
input, textarea, [contenteditable=""], [contenteditable="true"] {
|
|
96
|
+
-webkit-user-select: text;
|
|
97
|
+
user-select: text;
|
|
98
|
+
}
|
|
99
|
+
`;
|
|
100
|
+
document.head.appendChild(style);
|
|
101
|
+
}
|
|
67
102
|
// Constrains whatever the deck renders into #root to a 5:7 card in play mode.
|
|
68
103
|
// Hosts own max size and padding; the SDK only preserves the card aspect ratio.
|
|
69
104
|
function initPlayCard() {
|
|
@@ -85,6 +120,26 @@ function initPlayCard() {
|
|
|
85
120
|
}
|
|
86
121
|
`;
|
|
87
122
|
document.head.appendChild(style);
|
|
123
|
+
// Mark whatever this turns into a card, the way `initCard` marks the one it
|
|
124
|
+
// creates. Anything looking for "the card" -- a capture cropping to it, in
|
|
125
|
+
// page or headless -- otherwise finds nothing here and has to guess at the
|
|
126
|
+
// viewport, which is not the same rectangle: standalone chrome insets the
|
|
127
|
+
// card, so a viewport shot carries a border the card does not have.
|
|
128
|
+
//
|
|
129
|
+
// Runs on a timer as well as now because `setup()` is called before a deck
|
|
130
|
+
// renders, so #root is usually still empty at this point; a deck that mounts
|
|
131
|
+
// later still gets its card marked.
|
|
132
|
+
function markCard() {
|
|
133
|
+
const el = document.querySelector("#root > *");
|
|
134
|
+
if (el)
|
|
135
|
+
el.dataset.castleCard = "";
|
|
136
|
+
}
|
|
137
|
+
markCard();
|
|
138
|
+
requestAnimationFrame(markCard);
|
|
139
|
+
new MutationObserver(markCard).observe(document.documentElement, {
|
|
140
|
+
childList: true,
|
|
141
|
+
subtree: true,
|
|
142
|
+
});
|
|
88
143
|
function resize() {
|
|
89
144
|
const { w, h } = computeCardSize();
|
|
90
145
|
document.documentElement.style.setProperty("--castle-card-w", w + "px");
|
|
@@ -240,9 +295,36 @@ function formatConsoleArgs(args) {
|
|
|
240
295
|
})
|
|
241
296
|
.join(" ");
|
|
242
297
|
}
|
|
298
|
+
// Compositing a card needs html2canvas, and where it comes from decides whether
|
|
299
|
+
// a capture can fail for reasons that have nothing to do with the deck.
|
|
300
|
+
//
|
|
301
|
+
// The serve lends it from its own origin (`castle-web serve`, so: the editor,
|
|
302
|
+
// the dev server, a cloud sandbox) -- same origin, no third party, and it works
|
|
303
|
+
// with no internet at all. Every caller that captures runs against a serve, so
|
|
304
|
+
// this is the path in practice.
|
|
305
|
+
//
|
|
306
|
+
// The CDN is the fallback for a deck with no serve behind it, which today means
|
|
307
|
+
// a published deck -- where nothing captures. Keeping it costs nothing and means
|
|
308
|
+
// a capture from somewhere unforeseen still degrades to "slow" rather than
|
|
309
|
+
// "canvas-only". Loaded once and reused: html2canvas is ~194KB.
|
|
310
|
+
let html2canvasPromise = null;
|
|
311
|
+
function loadHtml2Canvas() {
|
|
312
|
+
if (!html2canvasPromise) {
|
|
313
|
+
const local = new URL("/__castle/vendor/html2canvas.js", location.origin)
|
|
314
|
+
.href;
|
|
315
|
+
html2canvasPromise = dynamicImport(local)
|
|
316
|
+
.catch(() => dynamicImport("https://esm.sh/html2canvas"))
|
|
317
|
+
.catch((err) => {
|
|
318
|
+
// Don't cache a failure -- a later capture may have a serve, or a network.
|
|
319
|
+
html2canvasPromise = null;
|
|
320
|
+
throw err;
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return html2canvasPromise;
|
|
324
|
+
}
|
|
243
325
|
async function captureWithHtml2Canvas(target) {
|
|
244
326
|
try {
|
|
245
|
-
const mod =
|
|
327
|
+
const mod = await loadHtml2Canvas();
|
|
246
328
|
const c = await mod.default(target, {
|
|
247
329
|
backgroundColor: null,
|
|
248
330
|
scale: devicePixelRatio,
|
|
@@ -266,15 +348,54 @@ function cropCanvasToCard(card, canvas) {
|
|
|
266
348
|
ctx.drawImage(canvas, dx, dy, canvasRect.width * devicePixelRatio, canvasRect.height * devicePixelRatio);
|
|
267
349
|
return c.toDataURL("image/png");
|
|
268
350
|
}
|
|
351
|
+
// Does the card show anything the canvas didn't draw? A deck is free to put raw
|
|
352
|
+
// DOM in the card -- a HUD, a title, a button -- and that DOM is part of what the
|
|
353
|
+
// player sees, so a canvas-only capture would be a cover that doesn't match the
|
|
354
|
+
// deck. Walks the card for any rendered node outside the canvas: an element with
|
|
355
|
+
// a non-empty box, or visible text.
|
|
356
|
+
function hasContentBesidesCanvas(card, canvas) {
|
|
357
|
+
const isRenderedBox = (el) => {
|
|
358
|
+
const style = getComputedStyle(el);
|
|
359
|
+
if (style.display === "none" ||
|
|
360
|
+
style.visibility === "hidden" ||
|
|
361
|
+
style.opacity === "0") {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
const rect = el.getBoundingClientRect();
|
|
365
|
+
return rect.width > 0 && rect.height > 0;
|
|
366
|
+
};
|
|
367
|
+
for (const el of Array.from(card.querySelectorAll("*"))) {
|
|
368
|
+
if (el === canvas || canvas.contains(el))
|
|
369
|
+
continue;
|
|
370
|
+
if (isRenderedBox(el))
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
// Text put straight in the card (or in a zero-box wrapper) renders without any
|
|
374
|
+
// element of its own to measure.
|
|
375
|
+
const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT);
|
|
376
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
377
|
+
if ((n.textContent || "").trim())
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
269
382
|
async function captureScreenshot() {
|
|
270
383
|
const card = document.querySelector("#castle-card, [data-castle-card]");
|
|
271
384
|
if (card) {
|
|
272
385
|
const cardCanvas = card.querySelector("canvas");
|
|
386
|
+
// Cropping the canvas is exact and needs no network, but it only tells the
|
|
387
|
+
// truth when the canvas IS the card. Otherwise composite the whole card --
|
|
388
|
+
// html2canvas draws canvases into its output, so nothing is lost by it.
|
|
389
|
+
if (cardCanvas && !hasContentBesidesCanvas(card, cardCanvas)) {
|
|
390
|
+
return cropCanvasToCard(card, cardCanvas);
|
|
391
|
+
}
|
|
392
|
+
const composited = await captureWithHtml2Canvas(card);
|
|
393
|
+
if (composited)
|
|
394
|
+
return composited;
|
|
395
|
+
// html2canvas is fetched at capture time, so it can fail closed (offline, a
|
|
396
|
+
// host CSP). A canvas-only cover beats no cover.
|
|
273
397
|
if (cardCanvas)
|
|
274
398
|
return cropCanvasToCard(card, cardCanvas);
|
|
275
|
-
const cropped = await captureWithHtml2Canvas(card);
|
|
276
|
-
if (cropped)
|
|
277
|
-
return cropped;
|
|
278
399
|
}
|
|
279
400
|
if (document.body?.dataset.castleScreenshotTarget === "viewport") {
|
|
280
401
|
const viewportCapture = await captureWithHtml2Canvas(document.body);
|
|
@@ -286,6 +407,36 @@ async function captureScreenshot() {
|
|
|
286
407
|
return canvas.toDataURL("image/png");
|
|
287
408
|
return captureWithHtml2Canvas(card || document.body);
|
|
288
409
|
}
|
|
410
|
+
// A framing host (the editor shell, for a deck's cover image) asks the deck for
|
|
411
|
+
// a capture instead of reaching into the frame for its canvas: the canvas is
|
|
412
|
+
// only part of what a deck renders, and only the deck can composite the rest.
|
|
413
|
+
// Answers the window that asked, falling back to the parent frame.
|
|
414
|
+
function initHostCapture() {
|
|
415
|
+
if (typeof window === "undefined" || window.parent === window)
|
|
416
|
+
return;
|
|
417
|
+
// A host re-asks while it waits, in case its first request landed before this
|
|
418
|
+
// handler existed. Same id means same request, so the repeat is dropped rather
|
|
419
|
+
// than starting a second capture alongside the one already running.
|
|
420
|
+
const inFlight = new Set();
|
|
421
|
+
window.addEventListener("message", (event) => {
|
|
422
|
+
const data = event.data;
|
|
423
|
+
if (!data || data.type !== HOST_CAPTURE_REQUEST)
|
|
424
|
+
return;
|
|
425
|
+
const requestId = typeof data.requestId === "string" ? data.requestId : undefined;
|
|
426
|
+
if (requestId) {
|
|
427
|
+
if (inFlight.has(requestId))
|
|
428
|
+
return;
|
|
429
|
+
inFlight.add(requestId);
|
|
430
|
+
}
|
|
431
|
+
const reply = (dataUrl) => {
|
|
432
|
+
if (requestId)
|
|
433
|
+
inFlight.delete(requestId);
|
|
434
|
+
const target = event.source ?? window.parent;
|
|
435
|
+
target.postMessage({ type: HOST_CAPTURE_RESULT, requestId, dataUrl, ok: !!dataUrl }, "*");
|
|
436
|
+
};
|
|
437
|
+
void captureScreenshot().then(reply, () => reply(null));
|
|
438
|
+
});
|
|
439
|
+
}
|
|
289
440
|
function connectLocal() {
|
|
290
441
|
fetch("/__castle/ws-port")
|
|
291
442
|
.then((r) => r.json())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "castle-web-sdk",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.14",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/castle.js",
|
|
6
6
|
"types": "dist/castle.d.ts",
|
|
@@ -10,12 +10,14 @@
|
|
|
10
10
|
"default": "./dist/castle.js"
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
|
-
"//host": "host.{js,d.ts} is the host-side executor
|
|
13
|
+
"//host": "host.{js,d.ts} is the host-side executor and leaderboardPanel.{js,d.ts} is the host-side leaderboard UI \u2014 both deliberately NOT exported and NOT packaged. They are vendored into host repos via scripts/copy-host-module.mjs; decks must never receive them.",
|
|
14
14
|
"files": [
|
|
15
15
|
"dist",
|
|
16
16
|
"README.md",
|
|
17
17
|
"!dist/host.js",
|
|
18
|
-
"!dist/host.d.ts"
|
|
18
|
+
"!dist/host.d.ts",
|
|
19
|
+
"!dist/leaderboardPanel.js",
|
|
20
|
+
"!dist/leaderboardPanel.d.ts"
|
|
19
21
|
],
|
|
20
22
|
"scripts": {
|
|
21
23
|
"build": "rm -rf dist && tsc",
|