@kubohiroya/turbowarp-svg-text 0.2.0 → 0.3.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 CHANGED
@@ -15,7 +15,7 @@ See the [English guide](https://kubohiroya.github.io/turbowarp-svg-text/) or [Ja
15
15
  - Immediately update visible bubbles and SVG text when a style is redefined or the stage size changes.
16
16
  - Keep legacy size-based say/think opcodes hidden but executable for saved-project compatibility.
17
17
 
18
- Animation is not included in version 0.1.0.
18
+ Animation is not included in this release.
19
19
 
20
20
  ## Blocks
21
21
 
@@ -42,7 +42,7 @@ Defines or replaces a named style. `ALIGN` accepts `left`, `center`, or `right`.
42
42
  | `up-left` | `northwest` |
43
43
  | `up-up-left` | `north-northwest` |
44
44
 
45
- Direction input is trimmed and case-insensitive. Existing eight-way values keep their original positions.
45
+ Direction input is trimmed and case-insensitive. Direction is measured from the actor center to the bubble body center. Changing the bubble width or height does not change the requested angle, although stage-edge clamping can move the bubble when necessary.
46
46
 
47
47
  `DIRECTION` also accepts any number from `0` through `360` and uses Scratch sprite direction semantics: `0` is up, `90` is right, `180` is down, `270` is left, and `360` is up again. Values between the named directions are placed continuously without rounding to the nearest of the sixteen menu positions. Values outside this range fall back to the initial `up-right` direction.
48
48
 
@@ -82,7 +82,7 @@ Style sizes are clamped to 1–1000. A blank or non-finite value uses 100.
82
82
  Load the following URL as a TurboWarp custom extension and allow it to run unsandboxed:
83
83
 
84
84
  ```text
85
- https://cdn.jsdelivr.net/npm/@kubohiroya/turbowarp-svg-text@0.1.0/dist/svg-text.js
85
+ https://cdn.jsdelivr.net/npm/@kubohiroya/turbowarp-svg-text@0.3.0/dist/svg-text.js
86
86
  ```
87
87
 
88
88
  ```text
@@ -91,6 +91,32 @@ say [Once upon a time...\nIn a village by the sea...] with style [narration]
91
91
  set this sprite text [Act One\nUrashima Taro] with style [narration]
92
92
  ```
93
93
 
94
+ ### Composition API
95
+
96
+ Composite runtimes can import `@kubohiroya/turbowarp-svg-text/composition` without registering the
97
+ Standalone extension or adding blocks. The caller supplies one TurboWarp runtime and explicit
98
+ targets.
99
+
100
+ ```js
101
+ import { createSvgTextComposition } from "@kubohiroya/turbowarp-svg-text/composition";
102
+
103
+ const svgText = createSvgTextComposition({ runtime: Scratch.vm.runtime });
104
+ svgText.defineStyle({
105
+ name: "title",
106
+ alignment: "center",
107
+ backgroundColor: "#112233",
108
+ direction: "up",
109
+ font: "Noto Sans JP",
110
+ fontPercent: 150,
111
+ textColor: "#ffffff",
112
+ });
113
+ svgText.setText({ target, text: "The End", styleName: "title" });
114
+ ```
115
+
116
+ Each composition owns its styles and the SVG skins it applies. Use `releaseTarget(target)` when a
117
+ target leaves the composition, or call the idempotent finalizer `releaseAll()` to destroy every
118
+ owned skin. Mutating methods reject calls after `releaseAll()`.
119
+
94
120
  ## Development
95
121
 
96
122
  ```bash
@@ -0,0 +1,918 @@
1
+ var block_definitions_default = {
2
+ extensionName: "SVG Text",
3
+ blocks: [
4
+ {
5
+ "opcode": "defineStyle",
6
+ "blockType": "COMMAND",
7
+ "text": "define text style [STYLE] background [BACKGROUND] text [TEXT_COLOR] font [FONT] size [SIZE] align [ALIGN] bubble direction [DIRECTION]",
8
+ "description": "Defines or replaces a named responsive text style. Bubble direction is measured from the actor center to the bubble body center and accepts names, compass aliases, or Scratch-style degrees from 0 to 360.",
9
+ "arguments": {
10
+ "STYLE": {
11
+ "type": "STRING",
12
+ "defaultValue": "default"
13
+ },
14
+ "BACKGROUND": {
15
+ "type": "COLOR",
16
+ "defaultValue": "#ffffff"
17
+ },
18
+ "TEXT_COLOR": {
19
+ "type": "COLOR",
20
+ "defaultValue": "#575e75"
21
+ },
22
+ "FONT": {
23
+ "type": "STRING",
24
+ "defaultValue": "Helvetica"
25
+ },
26
+ "SIZE": {
27
+ "type": "NUMBER",
28
+ "defaultValue": 100
29
+ },
30
+ "ALIGN": {
31
+ "type": "STRING",
32
+ "defaultValue": "left",
33
+ "menu": "alignment"
34
+ },
35
+ "DIRECTION": {
36
+ "type": "STRING",
37
+ "defaultValue": "up-right",
38
+ "menu": "direction"
39
+ }
40
+ }
41
+ },
42
+ {
43
+ "opcode": "setText",
44
+ "blockType": "COMMAND",
45
+ "text": "set this sprite text [TEXT] with style [STYLE]",
46
+ "description": "Replaces this sprite's skin with responsive styled SVG text.",
47
+ "arguments": {
48
+ "TEXT": {
49
+ "type": "STRING",
50
+ "defaultValue": "Title\\nSubtitle"
51
+ },
52
+ "STYLE": {
53
+ "type": "STRING",
54
+ "defaultValue": "default"
55
+ }
56
+ }
57
+ },
58
+ {
59
+ "opcode": "sayWithStyle",
60
+ "blockType": "COMMAND",
61
+ "text": "say [MESSAGE] with style [STYLE]",
62
+ "description": "Shows a responsive say bubble using a named style.",
63
+ "arguments": {
64
+ "MESSAGE": {
65
+ "type": "STRING",
66
+ "defaultValue": "Hello!\\nHow are you?"
67
+ },
68
+ "STYLE": {
69
+ "type": "STRING",
70
+ "defaultValue": "default"
71
+ }
72
+ }
73
+ },
74
+ {
75
+ "opcode": "thinkWithStyle",
76
+ "blockType": "COMMAND",
77
+ "text": "think [MESSAGE] with style [STYLE]",
78
+ "description": "Shows a responsive think bubble using a named style.",
79
+ "arguments": {
80
+ "MESSAGE": {
81
+ "type": "STRING",
82
+ "defaultValue": "Hmm...\\nI wonder."
83
+ },
84
+ "STYLE": {
85
+ "type": "STRING",
86
+ "defaultValue": "default"
87
+ }
88
+ }
89
+ },
90
+ {
91
+ "opcode": "say",
92
+ "blockType": "COMMAND",
93
+ "text": "say [MESSAGE] with font size [SIZE]",
94
+ "description": "Legacy size-based say bubble kept for saved-project compatibility.",
95
+ "hideFromPalette": true,
96
+ "arguments": {
97
+ "MESSAGE": {
98
+ "type": "STRING",
99
+ "defaultValue": "Hello!\\nHow are you?"
100
+ },
101
+ "SIZE": {
102
+ "type": "NUMBER",
103
+ "defaultValue": 100
104
+ }
105
+ }
106
+ },
107
+ {
108
+ "opcode": "think",
109
+ "blockType": "COMMAND",
110
+ "text": "think [MESSAGE] with font size [SIZE]",
111
+ "description": "Legacy size-based think bubble kept for saved-project compatibility.",
112
+ "hideFromPalette": true,
113
+ "arguments": {
114
+ "MESSAGE": {
115
+ "type": "STRING",
116
+ "defaultValue": "Hmm...\\nI wonder."
117
+ },
118
+ "SIZE": {
119
+ "type": "NUMBER",
120
+ "defaultValue": 100
121
+ }
122
+ }
123
+ }
124
+ ],
125
+ menus: {
126
+ "alignment": {
127
+ "acceptReporters": true,
128
+ "items": [
129
+ "left",
130
+ "center",
131
+ "right"
132
+ ]
133
+ },
134
+ "direction": {
135
+ "acceptReporters": true,
136
+ "items": [
137
+ "up",
138
+ "up-up-right",
139
+ "up-right",
140
+ "right-up-right",
141
+ "right",
142
+ "right-down-right",
143
+ "down-right",
144
+ "down-down-right",
145
+ "down",
146
+ "down-down-left",
147
+ "down-left",
148
+ "left-down-left",
149
+ "left",
150
+ "left-up-left",
151
+ "up-left",
152
+ "up-up-left"
153
+ ]
154
+ }
155
+ }
156
+ };
157
+ //#endregion
158
+ //#region src/config.ts
159
+ var extensionConfig = {
160
+ id: "kubohiroyasvgtext",
161
+ slug: "svg-text",
162
+ name: "SVG Text",
163
+ description: "Responsive named-style bubbles and SVG text actors for TurboWarp.",
164
+ author: "Hiroya Kubo",
165
+ license: "MPL-2.0",
166
+ unsandboxed: true
167
+ };
168
+ //#endregion
169
+ //#region src/extension.ts
170
+ var bubbleDirections = [
171
+ "up",
172
+ "up-up-right",
173
+ "up-right",
174
+ "right-up-right",
175
+ "right",
176
+ "right-down-right",
177
+ "down-right",
178
+ "down-down-right",
179
+ "down",
180
+ "down-down-left",
181
+ "down-left",
182
+ "left-down-left",
183
+ "left",
184
+ "left-up-left",
185
+ "up-left",
186
+ "up-up-left"
187
+ ];
188
+ var bubbleDirectionAliases = /* @__PURE__ */ new Map([
189
+ ["east", "right"],
190
+ ["east-northeast", "right-up-right"],
191
+ ["east-southeast", "right-down-right"],
192
+ ["north", "up"],
193
+ ["northeast", "up-right"],
194
+ ["north-northeast", "up-up-right"],
195
+ ["northwest", "up-left"],
196
+ ["north-northwest", "up-up-left"],
197
+ ["south", "down"],
198
+ ["southeast", "down-right"],
199
+ ["south-southeast", "down-down-right"],
200
+ ["southwest", "down-left"],
201
+ ["south-southwest", "down-down-left"],
202
+ ["west", "left"],
203
+ ["west-northwest", "left-up-left"],
204
+ ["west-southwest", "left-down-left"]
205
+ ]);
206
+ var intermediateDirectionOffset = Math.SQRT2 - 1;
207
+ var bubbleDirectionVectors = {
208
+ down: {
209
+ x: 0,
210
+ y: -1
211
+ },
212
+ "down-down-left": {
213
+ x: -intermediateDirectionOffset,
214
+ y: -1
215
+ },
216
+ "down-down-right": {
217
+ x: intermediateDirectionOffset,
218
+ y: -1
219
+ },
220
+ "down-left": {
221
+ x: -1,
222
+ y: -1
223
+ },
224
+ "down-right": {
225
+ x: 1,
226
+ y: -1
227
+ },
228
+ left: {
229
+ x: -1,
230
+ y: 0
231
+ },
232
+ "left-down-left": {
233
+ x: -1,
234
+ y: -intermediateDirectionOffset
235
+ },
236
+ "left-up-left": {
237
+ x: -1,
238
+ y: intermediateDirectionOffset
239
+ },
240
+ right: {
241
+ x: 1,
242
+ y: 0
243
+ },
244
+ "right-down-right": {
245
+ x: 1,
246
+ y: -intermediateDirectionOffset
247
+ },
248
+ "right-up-right": {
249
+ x: 1,
250
+ y: intermediateDirectionOffset
251
+ },
252
+ up: {
253
+ x: 0,
254
+ y: 1
255
+ },
256
+ "up-left": {
257
+ x: -1,
258
+ y: 1
259
+ },
260
+ "up-right": {
261
+ x: 1,
262
+ y: 1
263
+ },
264
+ "up-up-left": {
265
+ x: -intermediateDirectionOffset,
266
+ y: 1
267
+ },
268
+ "up-up-right": {
269
+ x: intermediateDirectionOffset,
270
+ y: 1
271
+ }
272
+ };
273
+ function normalizeVectorComponent(value) {
274
+ if (Math.abs(value) < 1e-12) return 0;
275
+ if (Math.abs(1 - Math.abs(value)) < 1e-12) return Math.sign(value);
276
+ return value;
277
+ }
278
+ function directionVector(direction) {
279
+ if (typeof direction === "string") return bubbleDirectionVectors[direction];
280
+ const radians = direction * Math.PI / 180;
281
+ const rawX = Math.sin(radians);
282
+ const rawY = Math.cos(radians);
283
+ return {
284
+ x: normalizeVectorComponent(rawX),
285
+ y: normalizeVectorComponent(rawY)
286
+ };
287
+ }
288
+ var blockDefinitions = block_definitions_default.blocks;
289
+ var definitionMenus = block_definitions_default.menus;
290
+ var EXTENSION_DOCS_URI = "https://kubohiroya.github.io/turbowarp-svg-text/";
291
+ var bubbleStateKey = "Scratch.looks";
292
+ var defaultStyleName$1 = "default";
293
+ var defaultFontPercent = 100;
294
+ var minimumFontPercent = 1;
295
+ var maximumFontPercent = 1e3;
296
+ var maximumFontNameLength = 128;
297
+ var baseStageWidth = 480;
298
+ var baseStageHeight = 360;
299
+ var baseStyle = {
300
+ maxLineWidth: 170,
301
+ minWidth: 50,
302
+ strokeWidth: 4,
303
+ padding: 10,
304
+ cornerRadius: 16,
305
+ tailHeight: 12,
306
+ fontSize: 14,
307
+ fontHeightRatio: .9,
308
+ lineHeight: 16
309
+ };
310
+ var actorStyle = {
311
+ minimumWidth: 1,
312
+ padding: 12,
313
+ cornerRadius: 8
314
+ };
315
+ var initialDefaultStyle = {
316
+ alignment: "left",
317
+ backgroundColor: "#ffffff",
318
+ direction: "up-right",
319
+ font: "Helvetica",
320
+ fontPercent: defaultFontPercent,
321
+ textColor: "#575e75"
322
+ };
323
+ var SvgTextExtension = class {
324
+ constructor(runtime = Scratch.vm?.runtime, options = {}) {
325
+ this.styles = /* @__PURE__ */ new Map([[defaultStyleName$1, initialDefaultStyle]]);
326
+ this.activeStyles = /* @__PURE__ */ new WeakMap();
327
+ this.textActors = /* @__PURE__ */ new Map();
328
+ this.pendingStyles = /* @__PURE__ */ new WeakMap();
329
+ this.alignedSkins = /* @__PURE__ */ new WeakSet();
330
+ this.targetPositionHooks = /* @__PURE__ */ new WeakMap();
331
+ if (!runtime) throw new Error("SVG Text requires the TurboWarp VM.");
332
+ this.runtime = runtime;
333
+ this.castToString = options.castToString ?? Scratch.Cast.toString;
334
+ this.handleSayOrThink = this.handleSayOrThink.bind(this);
335
+ this.handleStageSizeChanged = this.handleStageSizeChanged.bind(this);
336
+ if (options.listenForRuntimeEvents ?? true) {
337
+ this.runtime.on("SAY", this.handleSayOrThink);
338
+ this.runtime.on("STAGE_SIZE_CHANGED", this.handleStageSizeChanged);
339
+ }
340
+ }
341
+ getInfo() {
342
+ return {
343
+ id: extensionConfig.id,
344
+ name: Scratch.translate(block_definitions_default.extensionName),
345
+ docsURI: EXTENSION_DOCS_URI,
346
+ color1: "#9966ff",
347
+ blocks: blockDefinitions.map((block) => this.toScratchBlock(block)),
348
+ menus: definitionMenus
349
+ };
350
+ }
351
+ defineStyle(args) {
352
+ const styleName = this.normalizeStyleName(args.STYLE);
353
+ const definition = {
354
+ alignment: this.normalizeAlignment(args.ALIGN),
355
+ backgroundColor: this.normalizeColor(args.BACKGROUND, initialDefaultStyle.backgroundColor),
356
+ direction: this.normalizeDirection(args.DIRECTION),
357
+ font: this.normalizeFont(args.FONT),
358
+ fontPercent: this.normalizeFontPercent(args.SIZE),
359
+ textColor: this.normalizeColor(args.TEXT_COLOR, initialDefaultStyle.textColor)
360
+ };
361
+ this.styles.set(styleName, definition);
362
+ this.restyleVisibleBubbles(styleName, definition);
363
+ this.restyleTextActors(styleName);
364
+ }
365
+ setText(args, util) {
366
+ const selection = this.resolveStyle(args.STYLE);
367
+ this.applyTextActor(util.target, this.normalizeMessage(args.TEXT), selection);
368
+ }
369
+ releaseTextActor(target) {
370
+ const state = this.textActors.get(target);
371
+ if (!state) return false;
372
+ this.textActors.delete(target);
373
+ this.runtime.renderer?.destroySkin?.(state.skinId);
374
+ this.runtime.requestRedraw?.();
375
+ return true;
376
+ }
377
+ sayWithStyle(args, util) {
378
+ this.showStyledBubble("say", args, util);
379
+ }
380
+ thinkWithStyle(args, util) {
381
+ this.showStyledBubble("think", args, util);
382
+ }
383
+ say(args, util) {
384
+ this.showLegacyBubble("say", args, util);
385
+ }
386
+ think(args, util) {
387
+ this.showLegacyBubble("think", args, util);
388
+ }
389
+ toScratchBlock(block) {
390
+ return {
391
+ opcode: block.opcode,
392
+ blockType: Scratch.BlockType[block.blockType],
393
+ text: Scratch.translate(block.text),
394
+ hideFromPalette: block.hideFromPalette ?? false,
395
+ arguments: Object.fromEntries(Object.entries(block.arguments).map(([name, argument]) => [name, {
396
+ type: Scratch.ArgumentType[argument.type],
397
+ defaultValue: argument.defaultValue,
398
+ ...argument.menu === void 0 ? {} : { menu: argument.menu }
399
+ }]))
400
+ };
401
+ }
402
+ normalizeStyleName(value) {
403
+ return this.castToString(value).trim() || defaultStyleName$1;
404
+ }
405
+ normalizeFontPercent(value) {
406
+ if (typeof value === "string" && value.trim() === "") return defaultFontPercent;
407
+ const numericValue = Number(value);
408
+ if (!Number.isFinite(numericValue)) return defaultFontPercent;
409
+ return Math.min(maximumFontPercent, Math.max(minimumFontPercent, numericValue));
410
+ }
411
+ normalizeMessage(value) {
412
+ return this.castToString(value).replace(/\\r\\n|\\n|\\r/gu, "\n");
413
+ }
414
+ normalizeAlignment(value) {
415
+ const alignment = this.castToString(value).trim().toLowerCase();
416
+ if (alignment === "center" || alignment === "right") return alignment;
417
+ return "left";
418
+ }
419
+ normalizeDirection(value) {
420
+ const direction = this.castToString(value).trim().toLowerCase();
421
+ if (bubbleDirections.includes(direction)) return direction;
422
+ const alias = bubbleDirectionAliases.get(direction);
423
+ if (alias) return alias;
424
+ if (direction !== "") {
425
+ const degrees = Number(direction);
426
+ if (Number.isFinite(degrees) && degrees >= 0 && degrees <= 360) return degrees === 360 ? 0 : degrees;
427
+ }
428
+ return initialDefaultStyle.direction;
429
+ }
430
+ normalizeColor(value, fallback) {
431
+ const color = this.castToString(value).trim();
432
+ if (color === "") return fallback;
433
+ if (/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/iu.test(color)) return color;
434
+ if (globalThis.CSS?.supports?.("color", color)) return color;
435
+ return fallback;
436
+ }
437
+ normalizeFont(value) {
438
+ const font = this.castToString(value).trim();
439
+ const hasUnsafeCharacter = [...font].some((character) => {
440
+ const codePoint = character.codePointAt(0) ?? 0;
441
+ return codePoint <= 31 || codePoint === 127 || ",;{}".includes(character);
442
+ });
443
+ if (font === "" || font.length > maximumFontNameLength || hasUnsafeCharacter) return initialDefaultStyle.font;
444
+ return font;
445
+ }
446
+ getStageScale() {
447
+ const nativeSize = this.runtime.renderer?.getNativeSize?.();
448
+ if (!Array.isArray(nativeSize) || nativeSize.length < 2) return 1;
449
+ const width = Number(nativeSize[0]);
450
+ const height = Number(nativeSize[1]);
451
+ if (!(width > 0) || !(height > 0)) return 1;
452
+ return Math.min(width / baseStageWidth, height / baseStageHeight);
453
+ }
454
+ createRenderStyle(definition) {
455
+ const stageScale = this.getStageScale();
456
+ const fontScale = stageScale * (definition.fontPercent / defaultFontPercent);
457
+ return {
458
+ maxLineWidth: baseStyle.maxLineWidth * stageScale,
459
+ minWidth: baseStyle.minWidth * stageScale,
460
+ strokeWidth: baseStyle.strokeWidth * stageScale,
461
+ padding: baseStyle.padding * stageScale,
462
+ cornerRadius: baseStyle.cornerRadius * stageScale,
463
+ tailHeight: baseStyle.tailHeight * stageScale,
464
+ font: definition.font,
465
+ fontSize: baseStyle.fontSize * fontScale,
466
+ fontHeightRatio: baseStyle.fontHeightRatio,
467
+ lineHeight: baseStyle.lineHeight * fontScale,
468
+ bubbleFill: definition.backgroundColor,
469
+ textFill: definition.textColor,
470
+ textAlign: definition.alignment
471
+ };
472
+ }
473
+ createTextActorSvg(text, definition) {
474
+ const stageScale = this.getStageScale();
475
+ const fontScale = stageScale * (definition.fontPercent / defaultFontPercent);
476
+ const fontSize = baseStyle.fontSize * fontScale;
477
+ const lineHeight = baseStyle.lineHeight * fontScale;
478
+ const padding = actorStyle.padding * stageScale;
479
+ const cornerRadius = actorStyle.cornerRadius * stageScale;
480
+ const lines = text.split("\n");
481
+ const contentWidth = Math.max(actorStyle.minimumWidth, ...lines.map((line) => this.measureTextWidth(line, fontSize)));
482
+ const width = Math.max(1, Math.ceil(contentWidth + padding * 2));
483
+ const height = Math.max(1, Math.ceil(lineHeight * lines.length + padding * 2));
484
+ const alignment = definition.alignment;
485
+ const textAnchor = alignment === "center" ? "middle" : alignment === "right" ? "end" : "start";
486
+ const x = alignment === "center" ? width / 2 : alignment === "right" ? width - padding : padding;
487
+ const title = this.escapeXml(text);
488
+ const tspans = lines.map((line, index) => {
489
+ const y = padding + fontSize + lineHeight * index;
490
+ return `<tspan x="${this.formatSvgNumber(x)}" y="${this.formatSvgNumber(y)}">${this.escapeXml(line)}</tspan>`;
491
+ }).join("");
492
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img"><title>${title}</title><rect width="${width}" height="${height}" rx="${this.formatSvgNumber(cornerRadius)}" fill="${this.escapeXml(definition.backgroundColor)}"/><text xml:space="preserve" fill="${this.escapeXml(definition.textColor)}" font-family="${this.escapeXml(definition.font)}" font-size="${this.formatSvgNumber(fontSize)}" text-anchor="${textAnchor}">${tspans}</text></svg>`;
493
+ }
494
+ measureTextWidth(text, fontSize) {
495
+ let units = 0;
496
+ for (const character of text) {
497
+ if (/\p{Mark}/u.test(character)) continue;
498
+ if (/\s/u.test(character)) {
499
+ units += .35;
500
+ continue;
501
+ }
502
+ const codePoint = character.codePointAt(0) ?? 0;
503
+ units += codePoint <= 127 ? .62 : 1;
504
+ }
505
+ return units * fontSize;
506
+ }
507
+ escapeXml(value) {
508
+ return value.replace(/[&<>"']/gu, (character) => {
509
+ switch (character) {
510
+ case "&": return "&amp;";
511
+ case "<": return "&lt;";
512
+ case ">": return "&gt;";
513
+ case "\"": return "&quot;";
514
+ default: return "&apos;";
515
+ }
516
+ });
517
+ }
518
+ formatSvgNumber(value) {
519
+ return String(Math.round(value * 1e3) / 1e3);
520
+ }
521
+ applyTextActor(target, text, selection) {
522
+ const renderer = this.runtime.renderer;
523
+ if (typeof target.drawableID !== "number" || typeof renderer?.createSVGSkin !== "function" || typeof renderer.updateDrawableSkinId !== "function") throw new Error("SVG Text requires SVG skin APIs from TurboWarp.");
524
+ const skinId = renderer.createSVGSkin(this.createTextActorSvg(text, selection.definition));
525
+ if (!Number.isInteger(skinId) || skinId < 0) throw new Error("TurboWarp did not create an SVG text skin.");
526
+ try {
527
+ renderer.updateDrawableSkinId(target.drawableID, skinId);
528
+ } catch (error) {
529
+ renderer.destroySkin?.(skinId);
530
+ throw error;
531
+ }
532
+ const previous = this.textActors.get(target);
533
+ this.textActors.set(target, {
534
+ skinId,
535
+ styleName: selection.styleName ?? defaultStyleName$1,
536
+ text
537
+ });
538
+ if (previous && previous.skinId !== skinId) renderer.destroySkin?.(previous.skinId);
539
+ this.runtime.requestRedraw?.();
540
+ }
541
+ resolveStyle(value) {
542
+ const requestedName = this.normalizeStyleName(value);
543
+ const definition = this.styles.get(requestedName);
544
+ if (definition) return {
545
+ definition,
546
+ styleName: requestedName
547
+ };
548
+ return {
549
+ definition: this.styles.get(defaultStyleName$1) ?? initialDefaultStyle,
550
+ styleName: defaultStyleName$1
551
+ };
552
+ }
553
+ getBubbleState(target) {
554
+ const state = target.getCustomState?.(bubbleStateKey);
555
+ if (!state || typeof state !== "object") return null;
556
+ return state;
557
+ }
558
+ getTextBubbleSkin(skinId) {
559
+ const skins = this.runtime.renderer?._allSkins;
560
+ if (!skins) return null;
561
+ return (skins instanceof Map ? skins.get(skinId) : skins[skinId]) ?? null;
562
+ }
563
+ installAlignmentRenderer(skin) {
564
+ if (this.alignedSkins.has(skin)) return;
565
+ const originalRender = skin._renderTextBubble;
566
+ if (typeof originalRender !== "function") return;
567
+ skin._renderTextBubble = function renderAlignedTextBubble(scale) {
568
+ const style = this._style;
569
+ const alignment = style?.textAlign;
570
+ const textFill = style?.textFill;
571
+ if (!style || alignment !== "center" && alignment !== "right" || typeof textFill !== "string") {
572
+ originalRender.call(this, scale);
573
+ return;
574
+ }
575
+ style.textFill = "transparent";
576
+ try {
577
+ originalRender.call(this, scale);
578
+ } finally {
579
+ style.textFill = textFill;
580
+ }
581
+ const context = this._canvas?.getContext("2d");
582
+ const lines = this._lines;
583
+ const width = this._textAreaSize?.width;
584
+ const padding = style.padding;
585
+ const lineHeight = style.lineHeight;
586
+ const fontHeightRatio = style.fontHeightRatio;
587
+ const fontSize = style.fontSize;
588
+ if (!context || !lines || typeof width !== "number" || typeof padding !== "number" || typeof lineHeight !== "number" || typeof fontHeightRatio !== "number" || typeof fontSize !== "number") return;
589
+ context.save();
590
+ context.fillStyle = textFill;
591
+ context.textAlign = alignment;
592
+ const x = alignment === "center" ? width / 2 : width - padding;
593
+ for (let lineNumber = 0; lineNumber < lines.length; lineNumber += 1) {
594
+ const line = lines[lineNumber];
595
+ if (line === void 0) continue;
596
+ context.fillText(line, x, padding + lineHeight * lineNumber + fontHeightRatio * fontSize);
597
+ }
598
+ context.restore();
599
+ };
600
+ this.alignedSkins.add(skin);
601
+ }
602
+ installTargetPositionHook(target) {
603
+ const currentHook = target.onTargetVisualChange;
604
+ if (currentHook === this.targetPositionHooks.get(target)) return;
605
+ const originalHook = typeof currentHook === "function" ? currentHook : void 0;
606
+ const positionHook = (changedTarget) => {
607
+ originalHook?.(changedTarget);
608
+ this.positionBubble(target);
609
+ };
610
+ this.targetPositionHooks.set(target, positionHook);
611
+ target.onTargetVisualChange = positionHook;
612
+ }
613
+ positionBubble(target) {
614
+ if (target.visible === false) return;
615
+ const bubbleState = this.getBubbleState(target);
616
+ const renderer = this.runtime.renderer;
617
+ if (!bubbleState || typeof bubbleState.drawableId !== "number" || typeof renderer?.getCurrentSkinSize !== "function" || typeof renderer.updateDrawablePosition !== "function") return;
618
+ const size = renderer.getCurrentSkinSize(bubbleState.drawableId);
619
+ if (!Array.isArray(size) || size.length < 2) return;
620
+ const bubbleWidth = Number(size[0]);
621
+ const bubbleHeight = Number(size[1]);
622
+ if (!(bubbleWidth > 0) || !(bubbleHeight > 0)) return;
623
+ let targetBounds;
624
+ try {
625
+ targetBounds = target.getBoundsForBubble?.() ?? {
626
+ bottom: target.y ?? 0,
627
+ left: target.x ?? 0,
628
+ right: target.x ?? 0,
629
+ top: target.y ?? 0
630
+ };
631
+ } catch {
632
+ targetBounds = {
633
+ bottom: target.y ?? 0,
634
+ left: target.x ?? 0,
635
+ right: target.x ?? 0,
636
+ top: target.y ?? 0
637
+ };
638
+ }
639
+ const nativeSize = renderer.getNativeSize?.();
640
+ if (!Array.isArray(nativeSize) || nativeSize.length < 2) return;
641
+ const stageWidth = Number(nativeSize[0]);
642
+ const stageHeight = Number(nativeSize[1]);
643
+ if (!(stageWidth > 0) || !(stageHeight > 0)) return;
644
+ const direction = this.activeStyles.get(target)?.definition.direction ?? initialDefaultStyle.direction;
645
+ const centerX = (targetBounds.left + targetBounds.right) / 2;
646
+ const centerY = (targetBounds.top + targetBounds.bottom) / 2;
647
+ const gap = baseStyle.tailHeight * this.getStageScale();
648
+ const centeredBubbleX = centerX - bubbleWidth / 2;
649
+ const centeredBubbleY = centerY + bubbleHeight / 2;
650
+ const leftBubbleX = targetBounds.left - gap - bubbleWidth;
651
+ const rightBubbleX = targetBounds.right + gap;
652
+ const upperBubbleY = targetBounds.top + gap + bubbleHeight;
653
+ const lowerBubbleY = targetBounds.bottom - gap;
654
+ const vector = directionVector(direction);
655
+ const horizontalDistance = vector.x < 0 ? centeredBubbleX - leftBubbleX : rightBubbleX - centeredBubbleX;
656
+ const verticalDistance = vector.y < 0 ? centeredBubbleY - lowerBubbleY : upperBubbleY - centeredBubbleY;
657
+ const placementScale = Math.min(vector.x === 0 ? Number.POSITIVE_INFINITY : horizontalDistance / Math.abs(vector.x), vector.y === 0 ? Number.POSITIVE_INFINITY : verticalDistance / Math.abs(vector.y));
658
+ let x = centeredBubbleX + vector.x * placementScale;
659
+ let y = centeredBubbleY + vector.y * placementScale;
660
+ if (vector.x > 0 && !bubbleState.onSpriteRight) {
661
+ bubbleState.onSpriteRight = true;
662
+ this.updateTextSkin(bubbleState);
663
+ } else if (vector.x < 0 && bubbleState.onSpriteRight) {
664
+ bubbleState.onSpriteRight = false;
665
+ this.updateTextSkin(bubbleState);
666
+ }
667
+ const stageLeft = -stageWidth / 2;
668
+ const stageRight = stageWidth / 2;
669
+ const stageTop = stageHeight / 2;
670
+ const stageBottom = -stageHeight / 2;
671
+ x = this.clampPosition(x, stageLeft, stageRight - bubbleWidth);
672
+ y = this.clampPosition(y, stageBottom + bubbleHeight, stageTop);
673
+ renderer.updateDrawablePosition(bubbleState.drawableId, [x, y]);
674
+ }
675
+ updateTextSkin(bubbleState) {
676
+ if (typeof bubbleState.skinId !== "number") return;
677
+ this.runtime.renderer?.updateTextSkin?.(bubbleState.skinId, bubbleState.type, bubbleState.text, bubbleState.onSpriteRight, [0, 0]);
678
+ }
679
+ clampPosition(value, minimum, maximum) {
680
+ if (maximum < minimum) return minimum;
681
+ return Math.min(maximum, Math.max(minimum, value));
682
+ }
683
+ applyBubbleStyle(target, type, text, selection) {
684
+ const bubbleState = this.getBubbleState(target);
685
+ if (!bubbleState || typeof bubbleState.skinId !== "number") return;
686
+ const normalizedText = this.normalizeMessage(text);
687
+ if (bubbleState.text !== normalizedText) {
688
+ bubbleState.text = normalizedText;
689
+ this.runtime.renderer?.updateTextSkin?.(bubbleState.skinId, type, normalizedText, bubbleState.onSpriteRight, [0, 0]);
690
+ }
691
+ const skin = this.getTextBubbleSkin(bubbleState.skinId);
692
+ if (typeof skin?.setStyle === "function") {
693
+ this.installAlignmentRenderer(skin);
694
+ skin.setStyle(this.createRenderStyle(selection.definition));
695
+ }
696
+ this.installTargetPositionHook(target);
697
+ target.onTargetVisualChange?.(target);
698
+ this.runtime.requestRedraw?.();
699
+ }
700
+ handleSayOrThink(target, type, text) {
701
+ const selection = this.pendingStyles.get(target) ?? this.resolveStyle(defaultStyleName$1);
702
+ this.pendingStyles.delete(target);
703
+ const normalizedText = this.normalizeMessage(this.getBubbleState(target)?.text ?? text);
704
+ if (normalizedText === "") {
705
+ this.activeStyles.delete(target);
706
+ return;
707
+ }
708
+ this.activeStyles.set(target, selection);
709
+ this.applyBubbleStyle(target, type, normalizedText, selection);
710
+ }
711
+ handleStageSizeChanged() {
712
+ for (const target of this.runtime.targets ?? []) {
713
+ const bubbleState = this.getBubbleState(target);
714
+ if (!bubbleState?.text) continue;
715
+ const selection = this.activeStyles.get(target) ?? this.resolveStyle(defaultStyleName$1);
716
+ this.applyBubbleStyle(target, bubbleState.type, bubbleState.text, selection);
717
+ }
718
+ for (const [target, state] of [...this.textActors]) this.applyTextActor(target, state.text, this.resolveStyle(state.styleName));
719
+ }
720
+ restyleVisibleBubbles(styleName, definition) {
721
+ for (const target of this.runtime.targets ?? []) {
722
+ const selection = this.activeStyles.get(target);
723
+ const bubbleState = this.getBubbleState(target);
724
+ if (selection?.styleName !== styleName || !bubbleState?.text) continue;
725
+ const nextSelection = {
726
+ definition,
727
+ styleName
728
+ };
729
+ this.activeStyles.set(target, nextSelection);
730
+ this.applyBubbleStyle(target, bubbleState.type, bubbleState.text, nextSelection);
731
+ }
732
+ }
733
+ restyleTextActors(styleName) {
734
+ const definition = this.styles.get(styleName);
735
+ if (!definition) return;
736
+ for (const [target, state] of [...this.textActors]) {
737
+ if (state.styleName !== styleName) continue;
738
+ this.applyTextActor(target, state.text, {
739
+ definition,
740
+ styleName
741
+ });
742
+ }
743
+ }
744
+ showStyledBubble(type, args, util) {
745
+ this.showBubble(type, args.MESSAGE, this.resolveStyle(args.STYLE), util);
746
+ }
747
+ showLegacyBubble(type, args, util) {
748
+ const defaultDefinition = this.styles.get(defaultStyleName$1) ?? initialDefaultStyle;
749
+ this.showBubble(type, args.MESSAGE, {
750
+ definition: {
751
+ ...defaultDefinition,
752
+ fontPercent: this.normalizeFontPercent(args.SIZE)
753
+ },
754
+ styleName: null
755
+ }, util);
756
+ }
757
+ showBubble(type, messageValue, selection, util) {
758
+ const message = this.normalizeMessage(messageValue);
759
+ this.pendingStyles.set(util.target, selection);
760
+ try {
761
+ this.runtime.emit("SAY", util.target, type, message);
762
+ } finally {
763
+ this.pendingStyles.delete(util.target);
764
+ }
765
+ }
766
+ };
767
+ //#endregion
768
+ //#region src/composition.ts
769
+ var defaultStyleName = "default";
770
+ var directions = /* @__PURE__ */ new Set([
771
+ "up",
772
+ "up-up-right",
773
+ "up-right",
774
+ "right-up-right",
775
+ "right",
776
+ "right-down-right",
777
+ "down-right",
778
+ "down-down-right",
779
+ "down",
780
+ "down-down-left",
781
+ "down-left",
782
+ "left-down-left",
783
+ "left",
784
+ "left-up-left",
785
+ "up-left",
786
+ "up-up-left"
787
+ ]);
788
+ function isRecord(value) {
789
+ return typeof value === "object" && value !== null && !Array.isArray(value);
790
+ }
791
+ function compositionError(code, message) {
792
+ const error = new Error(message);
793
+ Object.defineProperty(error, "code", { value: code });
794
+ return error;
795
+ }
796
+ function requireExactKeys(value, required, optional, label) {
797
+ const allowed = /* @__PURE__ */ new Set([...required, ...optional]);
798
+ if (required.some((key) => !Object.prototype.hasOwnProperty.call(value, key)) || Object.keys(value).some((key) => !allowed.has(key))) throw compositionError("SVG-TEXT-COMPOSITION-001", `${label} has missing or unknown properties.`);
799
+ }
800
+ function requireName(value, label) {
801
+ if (typeof value !== "string" || value.trim().length === 0) throw compositionError("SVG-TEXT-COMPOSITION-001", `${label} must be a non-empty string.`);
802
+ return value.trim();
803
+ }
804
+ function validateRuntime(value) {
805
+ if (!isRecord(value) || !isRecord(value.renderer)) throw new TypeError("SVG Text composition runtime must provide a renderer.");
806
+ const renderer = value.renderer;
807
+ const methods = [
808
+ "createSVGSkin",
809
+ "destroySkin",
810
+ "updateDrawableSkinId"
811
+ ];
812
+ if (methods.some((method) => typeof renderer[method] !== "function")) throw new TypeError(`SVG Text composition renderer must provide ${methods.join(", ")}.`);
813
+ if (value.requestRedraw !== void 0 && typeof value.requestRedraw !== "function") throw new TypeError("SVG Text composition requestRedraw must be a function.");
814
+ return value;
815
+ }
816
+ function validateTarget(value) {
817
+ if (!isRecord(value) || typeof value.drawableID !== "number" || !Number.isInteger(value.drawableID) || value.drawableID < 0) throw compositionError("SVG-TEXT-COMPOSITION-002", "SVG Text target must provide a non-negative integer drawableID.");
818
+ return value;
819
+ }
820
+ function validateOptionalString(value, label) {
821
+ if (value !== void 0 && (typeof value !== "string" || value.length === 0)) throw compositionError("SVG-TEXT-COMPOSITION-001", `${label} must be a non-empty string when provided.`);
822
+ }
823
+ function validateStyle(value) {
824
+ if (!isRecord(value)) throw compositionError("SVG-TEXT-COMPOSITION-001", "SVG Text style must be an object.");
825
+ requireExactKeys(value, ["name"], [
826
+ "alignment",
827
+ "backgroundColor",
828
+ "direction",
829
+ "font",
830
+ "fontPercent",
831
+ "textColor"
832
+ ], "SVG Text style");
833
+ const name = requireName(value.name, "SVG Text style name");
834
+ if (value.alignment !== void 0 && value.alignment !== "left" && value.alignment !== "center" && value.alignment !== "right") throw compositionError("SVG-TEXT-COMPOSITION-001", "SVG Text alignment is invalid.");
835
+ validateOptionalString(value.backgroundColor, "SVG Text backgroundColor");
836
+ validateOptionalString(value.font, "SVG Text font");
837
+ validateOptionalString(value.textColor, "SVG Text textColor");
838
+ if (value.fontPercent !== void 0 && (typeof value.fontPercent !== "number" || !Number.isFinite(value.fontPercent) || value.fontPercent < 1 || value.fontPercent > 1e3)) throw compositionError("SVG-TEXT-COMPOSITION-001", "SVG Text fontPercent must be a finite number from 1 to 1000.");
839
+ if (value.direction !== void 0) {
840
+ if (!(typeof value.direction === "string" && directions.has(value.direction) || typeof value.direction === "number" && Number.isFinite(value.direction) && value.direction >= 0 && value.direction <= 360)) throw compositionError("SVG-TEXT-COMPOSITION-001", "SVG Text direction is invalid.");
841
+ }
842
+ return {
843
+ ...value,
844
+ name
845
+ };
846
+ }
847
+ function createSvgTextComposition(options) {
848
+ if (!isRecord(options)) throw new TypeError("SVG Text composition options must be an object.");
849
+ const extension = new SvgTextExtension(validateRuntime(options.runtime), {
850
+ castToString: (value) => String(value),
851
+ listenForRuntimeEvents: false
852
+ });
853
+ const styles = /* @__PURE__ */ new Set([defaultStyleName]);
854
+ const targets = /* @__PURE__ */ new Set();
855
+ let disposed = false;
856
+ function ensureActive() {
857
+ if (disposed) throw compositionError("SVG-TEXT-COMPOSITION-004", "SVG Text composition has been released.");
858
+ }
859
+ return Object.freeze({
860
+ defineStyle(input) {
861
+ ensureActive();
862
+ const style = validateStyle(input);
863
+ extension.defineStyle({
864
+ ALIGN: style.alignment ?? "",
865
+ BACKGROUND: style.backgroundColor ?? "",
866
+ DIRECTION: style.direction ?? "",
867
+ FONT: style.font ?? "",
868
+ SIZE: style.fontPercent ?? "",
869
+ STYLE: style.name,
870
+ TEXT_COLOR: style.textColor ?? ""
871
+ });
872
+ styles.add(style.name);
873
+ },
874
+ setText(input) {
875
+ ensureActive();
876
+ if (!isRecord(input)) throw compositionError("SVG-TEXT-COMPOSITION-001", "SVG Text actor input is invalid.");
877
+ requireExactKeys(input, [
878
+ "styleName",
879
+ "target",
880
+ "text"
881
+ ], [], "SVG Text actor input");
882
+ const target = validateTarget(input.target);
883
+ const styleName = requireName(input.styleName, "SVG Text styleName");
884
+ if (!styles.has(styleName)) throw compositionError("SVG-TEXT-COMPOSITION-003", `SVG Text style is not defined: ${styleName}`);
885
+ if (typeof input.text !== "string") throw compositionError("SVG-TEXT-COMPOSITION-001", "SVG Text text must be a string.");
886
+ extension.setText({
887
+ STYLE: styleName,
888
+ TEXT: input.text
889
+ }, { target });
890
+ targets.add(target);
891
+ },
892
+ releaseTarget(value) {
893
+ ensureActive();
894
+ const target = validateTarget(value);
895
+ if (!targets.delete(target)) throw compositionError("SVG-TEXT-COMPOSITION-005", "SVG Text target is not owned by this composition.");
896
+ if (!extension.releaseTextActor(target)) throw compositionError("SVG-TEXT-COMPOSITION-005", "SVG Text target ownership is inconsistent.");
897
+ },
898
+ releaseAll() {
899
+ if (disposed) return;
900
+ disposed = true;
901
+ const errors = [];
902
+ for (const target of targets) try {
903
+ extension.releaseTextActor(target);
904
+ } catch (error) {
905
+ errors.push(error);
906
+ }
907
+ targets.clear();
908
+ styles.clear();
909
+ if (errors.length > 0) {
910
+ const error = compositionError("SVG-TEXT-COMPOSITION-006", "SVG Text composition failed to release one or more skins.");
911
+ Object.defineProperty(error, "errors", { value: Object.freeze([...errors]) });
912
+ throw error;
913
+ }
914
+ }
915
+ });
916
+ }
917
+ //#endregion
918
+ export { createSvgTextComposition };
package/dist/svg-text.js CHANGED
@@ -24,7 +24,7 @@
24
24
  "opcode": "defineStyle",
25
25
  "blockType": "COMMAND",
26
26
  "text": "define text style [STYLE] background [BACKGROUND] text [TEXT_COLOR] font [FONT] size [SIZE] align [ALIGN] bubble direction [DIRECTION]",
27
- "description": "Defines or replaces a named responsive text style. Bubble direction accepts names, compass aliases, or Scratch-style degrees from 0 to 360.",
27
+ "description": "Defines or replaces a named responsive text style. Bubble direction is measured from the actor center to the bubble body center and accepts names, compass aliases, or Scratch-style degrees from 0 to 360.",
28
28
  "arguments": {
29
29
  "STYLE": {
30
30
  "type": "STRING",
@@ -329,7 +329,7 @@
329
329
  textColor: "#575e75"
330
330
  };
331
331
  var SvgTextExtension = class {
332
- constructor(runtime = Scratch.vm?.runtime) {
332
+ constructor(runtime = Scratch.vm?.runtime, options = {}) {
333
333
  this.styles = /* @__PURE__ */ new Map([[defaultStyleName, initialDefaultStyle]]);
334
334
  this.activeStyles = /* @__PURE__ */ new WeakMap();
335
335
  this.textActors = /* @__PURE__ */ new Map();
@@ -338,10 +338,13 @@
338
338
  this.targetPositionHooks = /* @__PURE__ */ new WeakMap();
339
339
  if (!runtime) throw new Error("SVG Text requires the TurboWarp VM.");
340
340
  this.runtime = runtime;
341
+ this.castToString = options.castToString ?? Scratch.Cast.toString;
341
342
  this.handleSayOrThink = this.handleSayOrThink.bind(this);
342
343
  this.handleStageSizeChanged = this.handleStageSizeChanged.bind(this);
343
- this.runtime.on("SAY", this.handleSayOrThink);
344
- this.runtime.on("STAGE_SIZE_CHANGED", this.handleStageSizeChanged);
344
+ if (options.listenForRuntimeEvents ?? true) {
345
+ this.runtime.on("SAY", this.handleSayOrThink);
346
+ this.runtime.on("STAGE_SIZE_CHANGED", this.handleStageSizeChanged);
347
+ }
345
348
  }
346
349
  getInfo() {
347
350
  return {
@@ -371,6 +374,14 @@
371
374
  const selection = this.resolveStyle(args.STYLE);
372
375
  this.applyTextActor(util.target, this.normalizeMessage(args.TEXT), selection);
373
376
  }
377
+ releaseTextActor(target) {
378
+ const state = this.textActors.get(target);
379
+ if (!state) return false;
380
+ this.textActors.delete(target);
381
+ this.runtime.renderer?.destroySkin?.(state.skinId);
382
+ this.runtime.requestRedraw?.();
383
+ return true;
384
+ }
374
385
  sayWithStyle(args, util) {
375
386
  this.showStyledBubble("say", args, util);
376
387
  }
@@ -397,7 +408,7 @@
397
408
  };
398
409
  }
399
410
  normalizeStyleName(value) {
400
- return Scratch.Cast.toString(value).trim() || defaultStyleName;
411
+ return this.castToString(value).trim() || defaultStyleName;
401
412
  }
402
413
  normalizeFontPercent(value) {
403
414
  if (typeof value === "string" && value.trim() === "") return defaultFontPercent;
@@ -406,15 +417,15 @@
406
417
  return Math.min(maximumFontPercent, Math.max(minimumFontPercent, numericValue));
407
418
  }
408
419
  normalizeMessage(value) {
409
- return Scratch.Cast.toString(value).replace(/\\r\\n|\\n|\\r/gu, "\n");
420
+ return this.castToString(value).replace(/\\r\\n|\\n|\\r/gu, "\n");
410
421
  }
411
422
  normalizeAlignment(value) {
412
- const alignment = Scratch.Cast.toString(value).trim().toLowerCase();
423
+ const alignment = this.castToString(value).trim().toLowerCase();
413
424
  if (alignment === "center" || alignment === "right") return alignment;
414
425
  return "left";
415
426
  }
416
427
  normalizeDirection(value) {
417
- const direction = Scratch.Cast.toString(value).trim().toLowerCase();
428
+ const direction = this.castToString(value).trim().toLowerCase();
418
429
  if (bubbleDirections.includes(direction)) return direction;
419
430
  const alias = bubbleDirectionAliases.get(direction);
420
431
  if (alias) return alias;
@@ -425,14 +436,14 @@
425
436
  return initialDefaultStyle.direction;
426
437
  }
427
438
  normalizeColor(value, fallback) {
428
- const color = Scratch.Cast.toString(value).trim();
439
+ const color = this.castToString(value).trim();
429
440
  if (color === "") return fallback;
430
441
  if (/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/iu.test(color)) return color;
431
442
  if (globalThis.CSS?.supports?.("color", color)) return color;
432
443
  return fallback;
433
444
  }
434
445
  normalizeFont(value) {
435
- const font = Scratch.Cast.toString(value).trim();
446
+ const font = this.castToString(value).trim();
436
447
  const hasUnsafeCharacter = [...font].some((character) => {
437
448
  const codePoint = character.codePointAt(0) ?? 0;
438
449
  return codePoint <= 31 || codePoint === 127 || ",;{}".includes(character);
@@ -519,7 +530,7 @@
519
530
  const renderer = this.runtime.renderer;
520
531
  if (typeof target.drawableID !== "number" || typeof renderer?.createSVGSkin !== "function" || typeof renderer.updateDrawableSkinId !== "function") throw new Error("SVG Text requires SVG skin APIs from TurboWarp.");
521
532
  const skinId = renderer.createSVGSkin(this.createTextActorSvg(text, selection.definition));
522
- if (typeof skinId !== "number") throw new Error("TurboWarp did not create an SVG text skin.");
533
+ if (!Number.isInteger(skinId) || skinId < 0) throw new Error("TurboWarp did not create an SVG text skin.");
523
534
  try {
524
535
  renderer.updateDrawableSkinId(target.drawableID, skinId);
525
536
  } catch (error) {
@@ -651,9 +662,9 @@
651
662
  const vector = directionVector(direction);
652
663
  const horizontalDistance = vector.x < 0 ? centeredBubbleX - leftBubbleX : rightBubbleX - centeredBubbleX;
653
664
  const verticalDistance = vector.y < 0 ? centeredBubbleY - lowerBubbleY : upperBubbleY - centeredBubbleY;
654
- const numericDirectionScale = typeof direction === "number" ? Math.min(vector.x === 0 ? Number.POSITIVE_INFINITY : horizontalDistance / Math.abs(vector.x), vector.y === 0 ? Number.POSITIVE_INFINITY : verticalDistance / Math.abs(vector.y)) : null;
655
- let x = centeredBubbleX + vector.x * (numericDirectionScale ?? horizontalDistance);
656
- let y = centeredBubbleY + vector.y * (numericDirectionScale ?? verticalDistance);
665
+ const placementScale = Math.min(vector.x === 0 ? Number.POSITIVE_INFINITY : horizontalDistance / Math.abs(vector.x), vector.y === 0 ? Number.POSITIVE_INFINITY : verticalDistance / Math.abs(vector.y));
666
+ let x = centeredBubbleX + vector.x * placementScale;
667
+ let y = centeredBubbleY + vector.y * placementScale;
657
668
  if (vector.x > 0 && !bubbleState.onSpriteRight) {
658
669
  bubbleState.onSpriteRight = true;
659
670
  this.updateTextSkin(bubbleState);
@@ -0,0 +1,39 @@
1
+ export type SvgTextAlignment = "center" | "left" | "right";
2
+ export type SvgTextDirection = "up" | "up-up-right" | "up-right" | "right-up-right" | "right" | "right-down-right" | "down-right" | "down-down-right" | "down" | "down-down-left" | "down-left" | "left-down-left" | "left" | "left-up-left" | "up-left" | "up-up-left" | number;
3
+ export interface SvgTextStyleInput {
4
+ name: string;
5
+ alignment?: SvgTextAlignment;
6
+ backgroundColor?: string;
7
+ direction?: SvgTextDirection;
8
+ font?: string;
9
+ fontPercent?: number;
10
+ textColor?: string;
11
+ }
12
+ export interface SvgTextTarget {
13
+ drawableID: number;
14
+ }
15
+ export interface SvgTextActorInput {
16
+ styleName: string;
17
+ target: SvgTextTarget;
18
+ text: string;
19
+ }
20
+ export interface SvgTextCompositionRenderer {
21
+ createSVGSkin(svg: string): number;
22
+ destroySkin(skinId: number): void;
23
+ getNativeSize?(): unknown;
24
+ updateDrawableSkinId(drawableId: number, skinId: number): void;
25
+ }
26
+ export interface SvgTextCompositionRuntime {
27
+ renderer: SvgTextCompositionRenderer;
28
+ requestRedraw?(): void;
29
+ }
30
+ export interface SvgTextComposition {
31
+ defineStyle(input: SvgTextStyleInput): void;
32
+ releaseAll(): void;
33
+ releaseTarget(target: SvgTextTarget): void;
34
+ setText(input: SvgTextActorInput): void;
35
+ }
36
+ export interface SvgTextCompositionOptions {
37
+ runtime: SvgTextCompositionRuntime;
38
+ }
39
+ export declare function createSvgTextComposition(options: SvgTextCompositionOptions): SvgTextComposition;
@@ -0,0 +1,9 @@
1
+ export declare const extensionConfig: {
2
+ readonly id: "kubohiroyasvgtext";
3
+ readonly slug: "svg-text";
4
+ readonly name: "SVG Text";
5
+ readonly description: "Responsive named-style bubbles and SVG text actors for TurboWarp.";
6
+ readonly author: "Hiroya Kubo";
7
+ readonly license: "MPL-2.0";
8
+ readonly unsandboxed: true;
9
+ };
@@ -0,0 +1,80 @@
1
+ interface LegacyBubbleArguments {
2
+ MESSAGE: unknown;
3
+ SIZE: unknown;
4
+ }
5
+ interface StyledBubbleArguments {
6
+ MESSAGE: unknown;
7
+ STYLE: unknown;
8
+ }
9
+ interface TextActorArguments {
10
+ STYLE: unknown;
11
+ TEXT: unknown;
12
+ }
13
+ interface DefineStyleArguments {
14
+ ALIGN: unknown;
15
+ BACKGROUND: unknown;
16
+ DIRECTION: unknown;
17
+ FONT: unknown;
18
+ SIZE: unknown;
19
+ STYLE: unknown;
20
+ TEXT_COLOR: unknown;
21
+ }
22
+ interface BlockUtility {
23
+ target: TurboWarpTarget;
24
+ }
25
+ interface SvgTextExtensionOptions {
26
+ castToString?: (value: unknown) => string;
27
+ listenForRuntimeEvents?: boolean;
28
+ }
29
+ export declare const EXTENSION_DOCS_URI = "https://kubohiroya.github.io/turbowarp-svg-text/";
30
+ export declare class SvgTextExtension implements TurboWarpExtension {
31
+ private readonly runtime;
32
+ private readonly castToString;
33
+ private readonly styles;
34
+ private readonly activeStyles;
35
+ private readonly textActors;
36
+ private readonly pendingStyles;
37
+ private readonly alignedSkins;
38
+ private readonly targetPositionHooks;
39
+ constructor(runtime?: any, options?: SvgTextExtensionOptions);
40
+ getInfo(): Record<string, unknown>;
41
+ defineStyle(args: DefineStyleArguments): void;
42
+ setText(args: TextActorArguments, util: BlockUtility): void;
43
+ releaseTextActor(target: TurboWarpTarget): boolean;
44
+ sayWithStyle(args: StyledBubbleArguments, util: BlockUtility): void;
45
+ thinkWithStyle(args: StyledBubbleArguments, util: BlockUtility): void;
46
+ say(args: LegacyBubbleArguments, util: BlockUtility): void;
47
+ think(args: LegacyBubbleArguments, util: BlockUtility): void;
48
+ private toScratchBlock;
49
+ private normalizeStyleName;
50
+ private normalizeFontPercent;
51
+ private normalizeMessage;
52
+ private normalizeAlignment;
53
+ private normalizeDirection;
54
+ private normalizeColor;
55
+ private normalizeFont;
56
+ private getStageScale;
57
+ private createRenderStyle;
58
+ private createTextActorSvg;
59
+ private measureTextWidth;
60
+ private escapeXml;
61
+ private formatSvgNumber;
62
+ private applyTextActor;
63
+ private resolveStyle;
64
+ private getBubbleState;
65
+ private getTextBubbleSkin;
66
+ private installAlignmentRenderer;
67
+ private installTargetPositionHook;
68
+ private positionBubble;
69
+ private updateTextSkin;
70
+ private clampPosition;
71
+ private applyBubbleStyle;
72
+ private handleSayOrThink;
73
+ private handleStageSizeChanged;
74
+ private restyleVisibleBubbles;
75
+ private restyleTextActors;
76
+ private showStyledBubble;
77
+ private showLegacyBubble;
78
+ private showBubble;
79
+ }
80
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-svg-text",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Responsive named-style bubbles and SVG text actors for TurboWarp.",
5
5
  "author": "Hiroya Kubo <hiroya@cuc.ac.jp>",
6
6
  "type": "module",
@@ -13,6 +13,15 @@
13
13
  "bugs": {
14
14
  "url": "https://github.com/kubohiroya/turbowarp-svg-text/issues"
15
15
  },
16
+ "exports": {
17
+ ".": "./dist/svg-text.js",
18
+ "./composition": {
19
+ "types": "./dist/types/composition.d.ts",
20
+ "import": "./dist/composition.js"
21
+ },
22
+ "./dist/svg-text.js": "./dist/svg-text.js",
23
+ "./package.json": "./package.json"
24
+ },
16
25
  "files": [
17
26
  "dist/",
18
27
  "README.md",
@@ -25,6 +34,27 @@
25
34
  "access": "public",
26
35
  "registry": "https://registry.npmjs.org/"
27
36
  },
37
+ "packageManager": "pnpm@11.18.0",
38
+ "scripts": {
39
+ "dev": "vite build --watch",
40
+ "typecheck": "tsc --noEmit",
41
+ "build": "pnpm build:extension && pnpm build:composition && pnpm build:types",
42
+ "build:extension": "vite build",
43
+ "build:composition": "vite build --config vite.composition.config.ts",
44
+ "build:types": "tsc --project tsconfig.build.json",
45
+ "composition:dist:check": "node scripts/check-composition-dist.mjs",
46
+ "types:composition:check": "tsc --project tsconfig.composition-consumer.json",
47
+ "lint": "eslint src tests scripts vite.config.ts eslint.config.mjs",
48
+ "format": "prettier --check .",
49
+ "format:write": "prettier --write .",
50
+ "test": "vitest run",
51
+ "docs:site:check": "node scripts/check-site.mjs",
52
+ "check:dist": "pnpm build && node scripts/check-dist.mjs",
53
+ "pack:check": "npm pack --dry-run --ignore-scripts",
54
+ "release:check": "pnpm publish --dry-run --access public",
55
+ "check": "pnpm typecheck && pnpm lint && pnpm format && pnpm test && pnpm docs:site:check && pnpm check:dist && pnpm composition:dist:check && pnpm types:composition:check && pnpm pack:check",
56
+ "prepack": "pnpm check"
57
+ },
28
58
  "devDependencies": {
29
59
  "@eslint/js": "^10.0.1",
30
60
  "@kubohiroya/vite-plugin-turbowarp-extension": "0.1.1",
@@ -45,19 +75,5 @@
45
75
  "text-actor",
46
76
  "named-styles",
47
77
  "multiline"
48
- ],
49
- "scripts": {
50
- "dev": "vite build --watch",
51
- "typecheck": "tsc --noEmit",
52
- "build": "vite build",
53
- "lint": "eslint src tests scripts vite.config.ts eslint.config.mjs",
54
- "format": "prettier --check .",
55
- "format:write": "prettier --write .",
56
- "test": "vitest run",
57
- "docs:site:check": "node scripts/check-site.mjs",
58
- "check:dist": "pnpm build && node scripts/check-dist.mjs",
59
- "pack:check": "npm pack --dry-run --ignore-scripts",
60
- "release:check": "pnpm publish --dry-run --access public",
61
- "check": "pnpm typecheck && pnpm lint && pnpm format && pnpm test && pnpm docs:site:check && pnpm check:dist && pnpm pack:check"
62
- }
63
- }
78
+ ]
79
+ }