@myrmidon/gve-snapshot-rendition 2.0.1 → 2.0.3

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/dist/index.js CHANGED
@@ -1385,7 +1385,7 @@ class TextRenderer {
1385
1385
  }
1386
1386
  // Apply r_t-value override if specified
1387
1387
  // This overrides the operation's value (the text being added) for display only
1388
- if (config.textValue) {
1388
+ if (config.textValue !== null && config.textValue !== undefined) {
1389
1389
  this._logger.debug("TextRenderer", `Applying r_t-value override: "${config.textValue}" (original text had ${nodes.length} characters)`);
1390
1390
  // Replace node data with characters from r_t-value
1391
1391
  // If r_t-value has fewer characters than nodes, we only override the first N nodes
@@ -1417,124 +1417,186 @@ class TextRenderer {
1417
1417
  // Use first RBR for positioning (additional text doesn't repeat per RBR)
1418
1418
  const rbr = rbrs[0];
1419
1419
  this._logger.debug("TextRenderer", `Using RBR for positioning`, rbr);
1420
- // 2. Calculate text position based on r_t-position + offsets
1421
- const position = config.textPosition || "o"; // Default to origin if not specified
1422
- const targetPos = this.calculateTargetPosition(position, rbr);
1423
- // Parse offsets using RBR bounds (offsets can be like "0.5th" = half of RBR height)
1420
+ // 2. Calculate position and offsets
1421
+ const position = config.textPosition || "o";
1424
1422
  const offsetX = parseOffset(config.textOffsetX || 0, rbr.height, rbr.width);
1425
1423
  const offsetY = parseOffset(config.textOffsetY || 0, rbr.height, rbr.width);
1426
- // Pre-calculate text width to correctly align the EBR with the RBR target point.
1427
- // calculateTargetPosition returns the RBR alignment point, but the text renderer
1428
- // lays characters left-to-right from baseX, so we must shift left so the intended
1429
- // edge or center of the EBR lands on that point.
1430
- // n/s/c/o: horizontal centers of EBR and RBR must coincide → shift left by half width
1431
- // w/nw/sw: EBR right edge at RBR target X → shift left by full width
1432
- const textWidth = this.calculateTotalTextWidth(nodes);
1433
- let alignedX = targetPos.x;
1434
- switch (position) {
1435
- case "n":
1436
- case "s":
1437
- case "c":
1438
- case "o":
1439
- alignedX -= textWidth / 2;
1440
- break;
1441
- case "w":
1442
- case "nw":
1443
- case "sw":
1444
- alignedX -= textWidth;
1445
- break;
1446
- }
1447
- const baseX = alignedX + offsetX;
1448
- const baseY = targetPos.y + offsetY;
1424
+ // 3. Render characters at preliminary positions (baseline y=0) into a hidden
1425
+ // temporary group so we can measure the actual visual EBR via getBBox().
1426
+ // This mirrors the hint renderer's approach: render measure reposition.
1427
+ // Using config font metrics here ensures correct character widths.
1428
+ const initialPositions = this.calculateAdditionalTextPositions(nodes, 0, 0, config);
1429
+ const tempGroup = createSVGElement("g");
1430
+ tempGroup.setAttribute("visibility", "hidden");
1431
+ rootSvg.appendChild(tempGroup);
1432
+ const measuredElements = [];
1433
+ for (let i = 0; i < nodes.length; i++) {
1434
+ const node = nodes[i];
1435
+ const pos = initialPositions[i];
1436
+ if (isLineBreak(node) || isSpace(node))
1437
+ continue;
1438
+ const textEl = createTextElement(node.data, {
1439
+ id: `n_${node.id}`,
1440
+ class: `node version-${versionTag}`,
1441
+ x: pos.x,
1442
+ y: pos.y,
1443
+ });
1444
+ applyTextStyle(textEl, {
1445
+ fontFamily: config.fontFamily,
1446
+ fontSize: config.fontSize,
1447
+ foreColor: config.foreColor,
1448
+ backColor: config.backColor,
1449
+ italic: config.italic,
1450
+ bold: config.bold,
1451
+ });
1452
+ tempGroup.appendChild(textEl);
1453
+ measuredElements.push({ el: textEl, node, initX: pos.x, initY: pos.y });
1454
+ }
1455
+ // 4. Measure actual visual EBR from the temp group
1456
+ const ebrBbox = getSafeBBox(tempGroup);
1457
+ const ebr = {
1458
+ x: ebrBbox.x,
1459
+ y: ebrBbox.y,
1460
+ width: ebrBbox.width,
1461
+ height: ebrBbox.height,
1462
+ right: ebrBbox.x + ebrBbox.width,
1463
+ bottom: ebrBbox.y + ebrBbox.height,
1464
+ };
1465
+ // 5. Compute translation: align EBR with RBR using the same logic as hints
1466
+ const rbrAlignPoint = this.calculateRBRAlignmentPoint(position, rbr);
1467
+ const ebrAlignPoint = this.calculateEBRAlignmentPoint(position, ebr);
1468
+ const dx = rbrAlignPoint.x - ebrAlignPoint.x + offsetX;
1469
+ const dy = rbrAlignPoint.y - ebrAlignPoint.y + offsetY;
1470
+ tempGroup.remove();
1449
1471
  this._logger.debug("TextRenderer", `Text position calculated`, {
1450
1472
  position,
1451
- targetPos,
1452
- textWidth,
1453
- alignedX,
1473
+ rbr,
1474
+ ebr,
1475
+ rbrAlignPoint,
1476
+ ebrAlignPoint,
1454
1477
  offsets: { x: offsetX, y: offsetY },
1455
- base: { x: baseX, y: baseY },
1478
+ translation: { dx, dy },
1456
1479
  });
1457
- // 3. Calculate positions for each character
1458
- const positions = this.calculateAdditionalTextPositions(nodes, baseX, baseY);
1459
- // 4. Calculate bounding rectangle for the text (needed for prolog)
1460
- const textBounds = this.calculateTextBounds(nodes, positions);
1461
- // 5. Check if prolog panning is needed (element visibility check)
1462
- // This must happen AFTER positioning, but BEFORE rendering characters
1480
+ // 6. Compute final textBounds for prolog check
1481
+ const textBounds = {
1482
+ x: ebr.x + dx,
1483
+ y: ebr.y + dy,
1484
+ width: ebr.width,
1485
+ height: ebr.height,
1486
+ right: ebr.right + dx,
1487
+ bottom: ebr.bottom + dy,
1488
+ };
1489
+ // 7. Check if prolog panning is needed
1463
1490
  if (panZoomInstance &&
1464
1491
  viewportWidth &&
1465
1492
  viewportHeight &&
1466
- this._settings.prologDuration > 0 &&
1467
- textBounds) {
1493
+ this._settings.prologDuration > 0) {
1468
1494
  const isVisible = this._animationEngine.isElementVisible(textBounds, panZoomInstance, viewportWidth, viewportHeight);
1469
1495
  if (!isVisible) {
1470
1496
  this._logger.debug("TextRenderer", `Additional text for ${versionTag} would be outside viewport, executing prolog`);
1471
1497
  await this._animationEngine.animateProlog(panZoomInstance, textBounds, viewportWidth, viewportHeight, this._settings.prologDuration);
1472
1498
  }
1473
1499
  }
1474
- // 6. Get animation function if specified
1500
+ // 8. Get animation function if specified
1475
1501
  const animationFn = this._settings.charAnimationId
1476
1502
  ? this._animationEngine
1477
1503
  .getFactory()
1478
1504
  .resolveAnimation(`#${this._settings.charAnimationId}`, this._settings.animations, "char")
1479
1505
  : undefined;
1480
- // 7. Render each character with features
1481
- for (let i = 0; i < nodes.length; i++) {
1482
- const node = nodes[i];
1483
- const pos = positions[i];
1484
- // Skip rendering for line breaks and spaces
1485
- if (isLineBreak(node) || isSpace(node)) {
1486
- continue;
1506
+ // 9. Move each element to its final position and add to the SVG
1507
+ for (const { el, node, initX, initY } of measuredElements) {
1508
+ el.setAttribute("x", String(initX + dx));
1509
+ el.setAttribute("y", String(initY + dy));
1510
+ rootSvg.appendChild(el);
1511
+ const bbox = getSafeBBox(el);
1512
+ this._boundsCache.set(`n_${node.id}`, bbox);
1513
+ if (animationFn) {
1514
+ el.style.opacity = "0";
1515
+ await this._animationEngine.animate(el, animationFn, rootSvg);
1487
1516
  }
1488
- await this.renderCharacter(node, pos, rootSvg, versionTag, animationFn, config);
1489
1517
  }
1490
1518
  this._logger.timeEnd(`renderAdditionalText-${versionTag}`);
1491
1519
  }
1492
1520
  /**
1493
1521
  * Calculate positions for additional text characters.
1494
1522
  * Additional text flows left to right from the base position.
1523
+ * When config is provided its font metrics are used for character width
1524
+ * measurement, matching what applyTextStyle will actually render.
1495
1525
  */
1496
- calculateAdditionalTextPositions(nodes, baseX, baseY) {
1526
+ calculateAdditionalTextPositions(nodes, baseX, baseY, config) {
1497
1527
  const positions = [];
1498
1528
  let currentX = baseX;
1499
1529
  const currentY = baseY;
1530
+ const fontSize = config?.fontSize ?? this._settings.fontSize;
1531
+ const fontFamily = config?.fontFamily ?? this._settings.fontFamily;
1532
+ const bold = config?.bold ?? this._settings.bold;
1533
+ const italic = config?.italic ?? this._settings.italic;
1500
1534
  for (let i = 0; i < nodes.length; i++) {
1501
1535
  const node = nodes[i];
1502
1536
  if (isLineBreak(node)) {
1503
- // Line breaks in additional text just get stored but don't render
1504
- positions.push({
1505
- x: currentX,
1506
- y: currentY,
1507
- nodeId: node.id,
1508
- lineNumber: 0,
1509
- });
1537
+ positions.push({ x: currentX, y: currentY, nodeId: node.id, lineNumber: 0 });
1510
1538
  }
1511
1539
  else if (isSpace(node)) {
1512
- // Space: advance by space width
1513
- const spaceWidth = this._settings.fontSize * 0.33;
1514
- positions.push({
1515
- x: currentX,
1516
- y: currentY,
1517
- nodeId: node.id,
1518
- lineNumber: 0,
1519
- });
1540
+ const spaceWidth = fontSize * 0.33;
1541
+ positions.push({ x: currentX, y: currentY, nodeId: node.id, lineNumber: 0 });
1520
1542
  currentX += spaceWidth + this._settings.charSpacing;
1521
1543
  }
1522
1544
  else {
1523
- // Regular character
1524
- positions.push({
1525
- x: currentX,
1526
- y: currentY,
1527
- nodeId: node.id,
1528
- lineNumber: 0,
1529
- });
1530
- // Calculate character width for next position
1531
- // Use measurement root for consistent font metrics with actual rendering
1532
- const charWidth = getTextWidth(node.data, this._settings.fontFamily, this._settings.fontSize, this._settings.bold, this._settings.italic, this._measurementRoot);
1545
+ positions.push({ x: currentX, y: currentY, nodeId: node.id, lineNumber: 0 });
1546
+ const charWidth = getTextWidth(node.data, fontFamily, fontSize, bold, italic, this._measurementRoot);
1533
1547
  currentX += charWidth + this._settings.charSpacing;
1534
1548
  }
1535
1549
  }
1536
1550
  return positions;
1537
1551
  }
1552
+ /**
1553
+ * Calculate the RBR anchor point for a given position type.
1554
+ * This is the point on the RBR where the corresponding EBR point will land.
1555
+ */
1556
+ calculateRBRAlignmentPoint(position, rbr) {
1557
+ const centerX = rbr.x + rbr.width / 2;
1558
+ const centerY = rbr.y + rbr.height / 2;
1559
+ switch (position) {
1560
+ case "n": return { x: centerX, y: rbr.y };
1561
+ case "ne": return { x: rbr.right, y: rbr.y };
1562
+ case "e": return { x: rbr.right, y: centerY };
1563
+ case "se": return { x: rbr.right, y: rbr.bottom };
1564
+ case "s": return { x: centerX, y: rbr.bottom };
1565
+ case "sw": return { x: rbr.x, y: rbr.bottom };
1566
+ case "w": return { x: rbr.x, y: centerY };
1567
+ case "nw": return { x: rbr.x, y: rbr.y };
1568
+ case "inw": return { x: rbr.x, y: rbr.y };
1569
+ case "ine": return { x: rbr.right, y: rbr.y };
1570
+ case "isw": return { x: rbr.x, y: rbr.bottom };
1571
+ case "ise": return { x: rbr.right, y: rbr.bottom };
1572
+ case "o":
1573
+ default: return { x: centerX, y: centerY };
1574
+ }
1575
+ }
1576
+ /**
1577
+ * Calculate the EBR anchor point for a given position type.
1578
+ * This is the point on the EBR that should coincide with the RBR anchor point.
1579
+ */
1580
+ calculateEBRAlignmentPoint(position, ebr) {
1581
+ const centerX = ebr.x + ebr.width / 2;
1582
+ const centerY = ebr.y + ebr.height / 2;
1583
+ switch (position) {
1584
+ case "n": return { x: centerX, y: ebr.bottom };
1585
+ case "ne": return { x: ebr.x, y: ebr.bottom };
1586
+ case "e": return { x: ebr.x, y: centerY };
1587
+ case "se": return { x: ebr.x, y: ebr.y };
1588
+ case "s": return { x: centerX, y: ebr.y };
1589
+ case "sw": return { x: ebr.right, y: ebr.y };
1590
+ case "w": return { x: ebr.right, y: centerY };
1591
+ case "nw": return { x: ebr.right, y: ebr.bottom };
1592
+ case "inw": return { x: ebr.x, y: ebr.y };
1593
+ case "ine": return { x: ebr.right, y: ebr.y };
1594
+ case "isw": return { x: ebr.x, y: ebr.bottom };
1595
+ case "ise": return { x: ebr.right, y: ebr.bottom };
1596
+ case "o":
1597
+ default: return { x: centerX, y: centerY };
1598
+ }
1599
+ }
1538
1600
  /**
1539
1601
  * Calculate RBRs from reference nodes.
1540
1602
  * Nodes on different lines create separate RBRs.
@@ -1577,96 +1639,6 @@ class TextRenderer {
1577
1639
  }
1578
1640
  return rbrs;
1579
1641
  }
1580
- /**
1581
- * Calculate target position on the RBR based on position type.
1582
- */
1583
- calculateTargetPosition(position, rbr) {
1584
- const centerX = rbr.x + rbr.width / 2;
1585
- const centerY = rbr.y + rbr.height / 2;
1586
- switch (position) {
1587
- case "n": // North (top center)
1588
- return { x: centerX, y: rbr.y };
1589
- case "ne": // Northeast (top right)
1590
- return { x: rbr.right, y: rbr.y };
1591
- case "e": // East (middle right)
1592
- return { x: rbr.right, y: centerY };
1593
- case "se": // Southeast (bottom right)
1594
- return { x: rbr.right, y: rbr.bottom };
1595
- case "s": // South (bottom center)
1596
- return { x: centerX, y: rbr.bottom };
1597
- case "sw": // Southwest (bottom left)
1598
- return { x: rbr.x, y: rbr.bottom };
1599
- case "w": // West (middle left)
1600
- return { x: rbr.x, y: centerY };
1601
- case "nw": // Northwest (top left)
1602
- return { x: rbr.x, y: rbr.y };
1603
- case "o": // Origin (center)
1604
- default:
1605
- return { x: centerX, y: centerY };
1606
- }
1607
- }
1608
- /**
1609
- * Calculate total rendered width of additional text nodes.
1610
- * Mirrors the spacing logic in calculateAdditionalTextPositions so the result
1611
- * is the exact horizontal span from the first character's left edge to the last
1612
- * character's right edge.
1613
- */
1614
- calculateTotalTextWidth(nodes) {
1615
- let totalWidth = 0;
1616
- let count = 0;
1617
- for (const node of nodes) {
1618
- if (isLineBreak(node))
1619
- continue;
1620
- if (isSpace(node)) {
1621
- totalWidth += this._settings.fontSize * 0.33 + this._settings.charSpacing;
1622
- count++;
1623
- continue;
1624
- }
1625
- const charWidth = getTextWidth(node.data, this._settings.fontFamily, this._settings.fontSize, this._settings.bold, this._settings.italic, this._measurementRoot);
1626
- totalWidth += charWidth + this._settings.charSpacing;
1627
- count++;
1628
- }
1629
- // charSpacing is added after every character; remove the trailing one
1630
- if (count > 0) {
1631
- totalWidth -= this._settings.charSpacing;
1632
- }
1633
- return Math.max(0, totalWidth);
1634
- }
1635
- /**
1636
- * Calculate bounding rectangle for a set of positioned characters.
1637
- */
1638
- calculateTextBounds(nodes, positions) {
1639
- if (nodes.length === 0)
1640
- return null;
1641
- // We need to estimate bounds before rendering
1642
- // Use approximate character dimensions based on settings
1643
- const charWidth = this._settings.fontSize * 0.6; // Approximate
1644
- const charHeight = this._settings.fontSize;
1645
- let minX = Infinity;
1646
- let minY = Infinity;
1647
- let maxX = -Infinity;
1648
- let maxY = -Infinity;
1649
- for (let i = 0; i < nodes.length; i++) {
1650
- const pos = positions[i];
1651
- const node = nodes[i];
1652
- if (isLineBreak(node) || isSpace(node))
1653
- continue;
1654
- minX = Math.min(minX, pos.x);
1655
- minY = Math.min(minY, pos.y - charHeight);
1656
- maxX = Math.max(maxX, pos.x + charWidth);
1657
- maxY = Math.max(maxY, pos.y);
1658
- }
1659
- if (!isFinite(minX))
1660
- return null;
1661
- return {
1662
- x: minX,
1663
- y: minY,
1664
- width: maxX - minX,
1665
- height: maxY - minY,
1666
- right: maxX,
1667
- bottom: maxY,
1668
- };
1669
- }
1670
1642
  /**
1671
1643
  * Update settings.
1672
1644
  */
@@ -25945,7 +25917,7 @@ class GveSnapshotRendition extends HTMLElement {
25945
25917
  * of the web component is loaded.
25946
25918
  */
25947
25919
  static get version() {
25948
- return "2.0.1";
25920
+ return "2.0.3";
25949
25921
  }
25950
25922
  constructor() {
25951
25923
  super();
@@ -25957,6 +25929,7 @@ class GveSnapshotRendition extends HTMLElement {
25957
25929
  this._currentVersionIndex = 0;
25958
25930
  this._autoForwardEnabled = false;
25959
25931
  this._autoForwardTimerId = null;
25932
+ this._renderScheduled = false;
25960
25933
  // Initialize with default settings
25961
25934
  this._settings = { ...DEFAULT_SETTINGS };
25962
25935
  this._autoForwardEnabled = this._settings.autoForwardOnGroup;
@@ -26029,14 +26002,20 @@ class GveSnapshotRendition extends HTMLElement {
26029
26002
  this._settings = { ...DEFAULT_SETTINGS, ...value };
26030
26003
  this._logger.setEnabled(this._settings.debug || false);
26031
26004
  this._logger.info("Settings updated", this._settings);
26032
- // When settings change, update renderers
26033
26005
  if (this._textRenderer) {
26034
26006
  this._textRenderer.updateSettings(this._settings);
26035
26007
  }
26036
26008
  if (this._hintRenderer) {
26037
26009
  this._hintRenderer.updateSettings(this._settings);
26038
26010
  }
26039
- this.render();
26011
+ // Build the UI shell on first call; after that just schedule a content
26012
+ // re-render so rapid settings+data assignments coalesce into one render.
26013
+ if (!this._container) {
26014
+ this.render();
26015
+ }
26016
+ else {
26017
+ this.scheduleRenderContent();
26018
+ }
26040
26019
  }
26041
26020
  /**
26042
26021
  * Data property (bindable).
@@ -26138,20 +26117,40 @@ class GveSnapshotRendition extends HTMLElement {
26138
26117
  });
26139
26118
  }
26140
26119
  }
26141
- // Trigger content rendering if renderers are ready
26120
+ // Schedule content rendering. Using scheduleRenderContent() rather than
26121
+ // a direct call ensures that when settings and data are both set in the
26122
+ // same synchronous turn (the common Angular pattern), only one render
26123
+ // fires after both assignments have completed.
26142
26124
  if (this._textRenderer && this._rootSvg && this._baseNodes.length > 0) {
26143
- this.renderContent();
26125
+ this.scheduleRenderContent();
26144
26126
  }
26145
26127
  }
26146
26128
  /**
26147
26129
  * Render the component UI.
26148
26130
  */
26149
26131
  render() {
26132
+ // Cancel any pending coalesced render — initializeGoldenLayout() will
26133
+ // trigger renderContent() once the new DOM is actually ready.
26134
+ this._renderScheduled = false;
26150
26135
  // Destroy existing GL instance before clearing the DOM
26151
26136
  if (this._goldenLayout) {
26152
26137
  this._goldenLayout.destroy();
26153
26138
  this._goldenLayout = undefined;
26154
26139
  }
26140
+ // Destroy pan/zoom — its internal state is tied to the old SVG element.
26141
+ // initializePanZoom() guards against double-init with a null-check,
26142
+ // so it must be reset here or it will silently skip re-initialization.
26143
+ if (this._panZoomInstance) {
26144
+ this._panZoomInstance.destroy();
26145
+ this._panZoomInstance = undefined;
26146
+ }
26147
+ // Kill in-flight GSAP animations on the old SVG tree.
26148
+ if (this._animationEngine) {
26149
+ this._animationEngine.killAll();
26150
+ }
26151
+ // Reset toolbar — processData() only creates it when this is null,
26152
+ // so leaving a stale reference would prevent toolbar rebuild.
26153
+ this._toolbar = undefined;
26155
26154
  this._shadow.innerHTML = "";
26156
26155
  // Add styles (includes inlined GL CSS for Shadow DOM compatibility)
26157
26156
  const style = document.createElement("style");
@@ -26362,6 +26361,22 @@ class GveSnapshotRendition extends HTMLElement {
26362
26361
  this._logger.error("GL", "Failed to reset layout", error);
26363
26362
  }
26364
26363
  }
26364
+ /**
26365
+ * Coalesce concurrent render requests into a single call.
26366
+ * Multiple same-turn invocations (e.g. settings setter + data setter both
26367
+ * firing in the same Angular expression) collapse into one microtask render,
26368
+ * always running after all synchronous assignments have completed so the
26369
+ * latest data is used.
26370
+ */
26371
+ scheduleRenderContent() {
26372
+ if (this._renderScheduled)
26373
+ return;
26374
+ this._renderScheduled = true;
26375
+ Promise.resolve().then(() => {
26376
+ this._renderScheduled = false;
26377
+ this.renderContent();
26378
+ });
26379
+ }
26365
26380
  /**
26366
26381
  * Render content (base text only - starts at v0).
26367
26382
  * Use goToVersionIndex() to navigate to specific versions.
@@ -40832,7 +40847,7 @@ function requireD () {
40832
40847
  + 'pragma private protected public pure ref return scope shared static struct '
40833
40848
  + 'super switch synchronized template this throw try typedef typeid typeof union '
40834
40849
  + 'unittest version void volatile while with __FILE__ __LINE__ __gshared|10 '
40835
- + '__thread __traits __DATE__ __EOF__ __TIME__ __TIMESTAMP__ __VENDOR__ 2.0.1',
40850
+ + '__thread __traits __DATE__ __EOF__ __TIME__ __TIMESTAMP__ __VENDOR__ 2.0.3',
40836
40851
  built_in:
40837
40852
  'bool cdouble cent cfloat char creal dchar delegate double dstring float function '
40838
40853
  + 'idouble ifloat ireal long real short string ubyte ucent uint ulong ushort wchar '
@@ -90615,9 +90630,11 @@ class GveHintDesigner extends HTMLElement {
90615
90630
  const loadDataBtn = this.createButton("load-data", "upload", "Load data from file");
90616
90631
  const clearDataBtn = this.createButton("clear-data", "x-circle", "Clear all data");
90617
90632
  clearDataBtn.classList.add("btn-danger");
90633
+ const exportInkscapeBtn = this.createButton("export-inkscape", "external-link", "Export hint to InkScape SVG");
90618
90634
  dataGroup.appendChild(saveDataBtn);
90619
90635
  dataGroup.appendChild(loadDataBtn);
90620
90636
  dataGroup.appendChild(clearDataBtn);
90637
+ dataGroup.appendChild(exportInkscapeBtn);
90621
90638
  toolbar.appendChild(dataGroup);
90622
90639
  return toolbar;
90623
90640
  }
@@ -91065,6 +91082,7 @@ class GveHintDesigner extends HTMLElement {
91065
91082
  this.addClickListener("save-data", () => this.saveData());
91066
91083
  this.addClickListener("load-data", () => this.loadData());
91067
91084
  this.addClickListener("clear-data", () => this.clearData());
91085
+ this.addClickListener("export-inkscape", () => this.exportToInkscape());
91068
91086
  // Timeline player
91069
91087
  this.addClickListener("play", () => this.playAnimation());
91070
91088
  this.addClickListener("pause", () => this.pauseAnimation());
@@ -92339,6 +92357,76 @@ class GveHintDesigner extends HTMLElement {
92339
92357
  this.showMessage("All data cleared", "success");
92340
92358
  this._logger.info("Data cleared");
92341
92359
  }
92360
+ /**
92361
+ * Export the current hint as an InkScape-compatible SVG file.
92362
+ */
92363
+ exportToInkscape() {
92364
+ if (!this._hintId) {
92365
+ this.showMessage("No hint selected!", "error");
92366
+ return;
92367
+ }
92368
+ const hint = this._data.hints[this._hintId];
92369
+ if (!hint?.svg) {
92370
+ this.showMessage("No SVG to export!", "error");
92371
+ return;
92372
+ }
92373
+ const w = this._settings.hintDesignWidth;
92374
+ const h = this._settings.hintDesignHeight;
92375
+ const hw = w / 2;
92376
+ const hh = h / 2;
92377
+ const resolvedSvg = this.resolveVariables(hint.svg);
92378
+ const unresolvedMatches = resolvedSvg.match(/\{\{[^}]+\}\}/g);
92379
+ const svgContent = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n` +
92380
+ `<svg\n` +
92381
+ ` width="${w}"\n` +
92382
+ ` height="${h}"\n` +
92383
+ ` viewBox="0 0 ${w} ${h}"\n` +
92384
+ ` version="1.1"\n` +
92385
+ ` id="svg1"\n` +
92386
+ ` sodipodi:docname="${this._hintId}.svg"\n` +
92387
+ ` xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"\n` +
92388
+ ` xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"\n` +
92389
+ ` xmlns="http://www.w3.org/2000/svg"\n` +
92390
+ ` xmlns:svg="http://www.w3.org/2000/svg">\n` +
92391
+ ` <sodipodi:namedview\n` +
92392
+ ` id="namedview1"\n` +
92393
+ ` pagecolor="#ffffff"\n` +
92394
+ ` bordercolor="#000000"\n` +
92395
+ ` borderopacity="0.25"\n` +
92396
+ ` inkscape:showpageshadow="2"\n` +
92397
+ ` inkscape:pageopacity="0.0"\n` +
92398
+ ` inkscape:pagecheckerboard="false"\n` +
92399
+ ` inkscape:deskcolor="#d1d1d1"\n` +
92400
+ ` inkscape:document-units="px"\n` +
92401
+ ` inkscape:zoom="3"\n` +
92402
+ ` inkscape:cx="${hw}"\n` +
92403
+ ` inkscape:cy="${hh}"\n` +
92404
+ ` inkscape:window-maximized="0"\n` +
92405
+ ` inkscape:current-layer="g1"\n` +
92406
+ ` showgrid="false" />\n` +
92407
+ ` <defs id="defs1" />\n` +
92408
+ ` <g\n` +
92409
+ ` inkscape:label="Layer 1"\n` +
92410
+ ` inkscape:groupmode="layer"\n` +
92411
+ ` id="layer1" />\n` +
92412
+ ` ${resolvedSvg}\n` +
92413
+ `</svg>`;
92414
+ const blob = new Blob([svgContent], { type: "image/svg+xml" });
92415
+ const url = URL.createObjectURL(blob);
92416
+ const link = document.createElement("a");
92417
+ link.href = url;
92418
+ link.download = `${this._hintId}.svg`;
92419
+ link.click();
92420
+ URL.revokeObjectURL(url);
92421
+ if (unresolvedMatches) {
92422
+ const names = [...new Set(unresolvedMatches)].join(", ");
92423
+ this.showMessage(`Exported "${this._hintId}.svg" — warning: unresolved variables: ${names}`, "info");
92424
+ }
92425
+ else {
92426
+ this.showMessage(`Exported "${this._hintId}.svg" for InkScape`, "success");
92427
+ }
92428
+ this._logger.info("Exported hint to InkScape", this._hintId);
92429
+ }
92342
92430
  /**
92343
92431
  * Load animation code from catalog.
92344
92432
  */