@myrmidon/gve-snapshot-rendition 2.0.9 → 2.0.10

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
@@ -12,6 +12,8 @@ const DEFAULT_SETTINGS = {
12
12
  underline: undefined,
13
13
  overline: undefined,
14
14
  strike: undefined,
15
+ textLineStyle: undefined,
16
+ textLineColor: undefined,
15
17
  // Spacing settings
16
18
  spaceWidthFraction: 0.33,
17
19
  charSpacing: 2,
@@ -695,9 +697,13 @@ function applyTextStyle(element, style) {
695
697
  attrs["font-style"] = "italic";
696
698
  if (style.bold)
697
699
  attrs["font-weight"] = "bold";
698
- if (style.rotate)
699
- attrs["transform"] = `rotate(${style.rotate})`;
700
- // Text decorations
700
+ setAttributes(element, attrs);
701
+ // Text decorations. Applied via inline CSS longhands (not the SVG
702
+ // text-decoration attribute) so that thickness, line style and color are
703
+ // honored by the text layout engine and follow the glyphs when the
704
+ // character is rotated. NOTE: per-character rotation is NOT handled here,
705
+ // because a plain rotate() would spin the glyph around the SVG origin;
706
+ // renderers apply rotate(deg, x, y) around each character's own anchor.
701
707
  const decorations = [];
702
708
  if (style.underline)
703
709
  decorations.push("underline");
@@ -706,9 +712,20 @@ function applyTextStyle(element, style) {
706
712
  if (style.strike)
707
713
  decorations.push("line-through");
708
714
  if (decorations.length > 0) {
709
- attrs["text-decoration"] = decorations.join(" ");
715
+ element.style.textDecorationLine = decorations.join(" ");
716
+ // CSS has a single thickness for all decoration lines of an element:
717
+ // use the first non-zero one (they are rarely combined).
718
+ const thickness = style.underline || style.overline || style.strike;
719
+ if (thickness && thickness > 0) {
720
+ element.style.textDecorationThickness = `${thickness}px`;
721
+ }
722
+ if (style.textLineStyle) {
723
+ element.style.textDecorationStyle = style.textLineStyle;
724
+ }
725
+ if (style.textLineColor) {
726
+ element.style.textDecorationColor = style.textLineColor;
727
+ }
710
728
  }
711
- setAttributes(element, attrs);
712
729
  }
713
730
  /**
714
731
  * Parse SVG from string and return the root element.
@@ -803,6 +820,32 @@ function getTransformedBBox(element) {
803
820
  return new DOMRect(0, 0, 0, 0);
804
821
  }
805
822
  }
823
+ /**
824
+ * Compute the axis-aligned bounding box of a rectangle rotated around a
825
+ * point, using SVG rotation direction (positive = clockwise, y-axis down).
826
+ *
827
+ * Used to cache correct bounds for per-character rotated text: getBBox()
828
+ * returns LOCAL coordinates which ignore the rotate(deg, x, y) transform
829
+ * applied to the element, while getTransformedBBox() would also bake in
830
+ * ancestor transforms (e.g. the pan/zoom viewport), which must stay out
831
+ * of the bounds cache.
832
+ */
833
+ function rotateRectAroundPoint(rect, degrees, cx, cy) {
834
+ const rad = (degrees * Math.PI) / 180;
835
+ const cos = Math.cos(rad);
836
+ const sin = Math.sin(rad);
837
+ const corners = [
838
+ [rect.x, rect.y],
839
+ [rect.x + rect.width, rect.y],
840
+ [rect.x, rect.y + rect.height],
841
+ [rect.x + rect.width, rect.y + rect.height],
842
+ ];
843
+ const xs = corners.map(([x, y]) => cx + (x - cx) * cos - (y - cy) * sin);
844
+ const ys = corners.map(([x, y]) => cy + (x - cx) * sin + (y - cy) * cos);
845
+ const minX = Math.min(...xs);
846
+ const minY = Math.min(...ys);
847
+ return new DOMRect(minX, minY, Math.max(...xs) - minX, Math.max(...ys) - minY);
848
+ }
806
849
  /**
807
850
  * Get computed text width for a given text and style.
808
851
  * Creates a temporary SVG element to measure.
@@ -921,16 +964,30 @@ function parseOffset(value, textHeight, textWidth) {
921
964
  }
922
965
  /**
923
966
  * Replace placeholders in SVG content.
924
- * Placeholders are in the format {{featureName}}.
967
+ * Placeholders are in the format {{featureName}}, or a fallback chain
968
+ * {{name ?? other ?? ...}}: names are tried left to right and the first one
969
+ * having a variable wins. There is no limit to the number of fallbacks.
970
+ * For instance {{r_fore-color ?? r_color}} resolves to the value of
971
+ * r_fore-color when defined, otherwise to the value of r_color.
925
972
  *
926
973
  * @param svgContent - The SVG content with placeholders
927
974
  * @param variables - Map of variable names to values
928
975
  * @returns SVG content with placeholders replaced
929
976
  */
930
977
  function resolvePlaceholders(svgContent, variables) {
931
- // Match {{placeholder}} where placeholder can contain letters, numbers, hyphens, and underscores
932
- return svgContent.replace(/\{\{([\w\-]+)\}\}/g, (match, name) => {
933
- return variables.get(name) || match; // Keep placeholder if not found
978
+ // Match {{name}} or {{name ?? name ?? ...}} where each name can contain
979
+ // letters, numbers, hyphens, and underscores
980
+ return svgContent.replace(/\{\{\s*([\w\-]+(?:\s*\?\?\s*[\w\-]+)*)\s*\}\}/g, (match, chain) => {
981
+ const names = chain.split("??").map((n) => n.trim());
982
+ for (const name of names) {
983
+ const value = variables.get(name);
984
+ // An empty string is a defined value; only a missing variable
985
+ // falls through to the next name in the chain
986
+ if (value !== undefined) {
987
+ return value;
988
+ }
989
+ }
990
+ return match; // Keep placeholder if no name in the chain resolves
934
991
  });
935
992
  }
936
993
 
@@ -1028,12 +1085,17 @@ function parseDisplacedSpan(value) {
1028
1085
  */
1029
1086
  function parseHintVars(value) {
1030
1087
  const result = new Map();
1031
- // Match pattern name=value
1088
+ // Match pattern name=value, splitting at the first "=" only so that
1089
+ // values containing "=" (e.g. URLs with query strings) survive intact
1032
1090
  const pairs = value.trim().split(/\s+/);
1033
1091
  for (const pair of pairs) {
1034
- const [name, val] = pair.split("=");
1092
+ const sepIndex = pair.indexOf("=");
1093
+ if (sepIndex <= 0)
1094
+ continue;
1095
+ const name = pair.substring(0, sepIndex).trim();
1096
+ const val = pair.substring(sepIndex + 1).trim();
1035
1097
  if (name && val) {
1036
- result.set(name.trim(), val.trim());
1098
+ result.set(name, val);
1037
1099
  }
1038
1100
  }
1039
1101
  return result;
@@ -1300,9 +1362,11 @@ class TextRenderer {
1300
1362
  const node = nodes[i];
1301
1363
  const pos = positions[i];
1302
1364
  // Cache bounds for spaces even though they're not rendered
1303
- // This is needed for RBR calculations that include spaces
1365
+ // This is needed for RBR calculations that include spaces.
1366
+ // Use the layout's space width (reference char width × fraction) so the
1367
+ // cached bounds match the horizontal advance actually applied.
1304
1368
  if (isSpace(node)) {
1305
- const spaceWidth = this._settings.fontSize * this._settings.spaceWidthFraction;
1369
+ const spaceWidth = this._textLayout.getSpaceWidth();
1306
1370
  const spaceY = pos.y - this._settings.fontSize; // Approximate top of character
1307
1371
  // Create a DOMRect-like object for the space bounds
1308
1372
  const spaceBounds = {
@@ -1352,6 +1416,11 @@ class TextRenderer {
1352
1416
  backColor: config.backColor,
1353
1417
  italic: config.italic,
1354
1418
  bold: config.bold,
1419
+ underline: config.underline,
1420
+ overline: config.overline,
1421
+ strike: config.strike,
1422
+ textLineStyle: config.textLineStyle,
1423
+ textLineColor: config.textLineColor,
1355
1424
  });
1356
1425
  }
1357
1426
  else {
@@ -1362,12 +1431,24 @@ class TextRenderer {
1362
1431
  backColor: this._settings.backColor,
1363
1432
  italic: this._settings.italic,
1364
1433
  bold: this._settings.bold,
1434
+ underline: this._settings.underline,
1435
+ overline: this._settings.overline,
1436
+ strike: this._settings.strike,
1437
+ textLineStyle: this._settings.textLineStyle,
1438
+ textLineColor: this._settings.textLineColor,
1365
1439
  });
1366
1440
  }
1441
+ // Per-character rotation (r_rotate): rotate each glyph around its own
1442
+ // anchor point (the character's baseline position), not the SVG origin.
1443
+ const rotate = config?.rotate;
1444
+ if (rotate) {
1445
+ textEl.setAttribute("transform", `rotate(${rotate}, ${position.x}, ${position.y})`);
1446
+ }
1367
1447
  rootSvg.appendChild(textEl);
1368
- // Cache bounds
1448
+ // Cache bounds. getBBox() ignores the element's own transform, so for
1449
+ // rotated characters the cached rect is recomputed around the anchor.
1369
1450
  const bbox = getSafeBBox(textEl);
1370
- this._boundsCache.set(`n_${node.id}`, bbox);
1451
+ this._boundsCache.set(`n_${node.id}`, rotate ? rotateRectAroundPoint(bbox, rotate, position.x, position.y) : bbox);
1371
1452
  // Animate if function provided
1372
1453
  if (animationFn) {
1373
1454
  // Set initial opacity to 0 for animation via CSS style (GSAP animates CSS)
@@ -1394,6 +1475,7 @@ class TextRenderer {
1394
1475
  this._logger.info(`Rendering ${nodes.length} additional text characters for ${versionTag}`);
1395
1476
  if (nodes.length === 0) {
1396
1477
  this._logger.warn("No nodes to render");
1478
+ this._logger.timeEnd(`renderAdditionalText-${versionTag}`);
1397
1479
  return;
1398
1480
  }
1399
1481
  // Apply r_t-value override if specified
@@ -1410,8 +1492,8 @@ class TextRenderer {
1410
1492
  }
1411
1493
  // If r_t-value has fewer characters, we need to only render those nodes
1412
1494
  if (overrideChars.length < nodes.length) {
1413
- nodes = nodes.slice(0, overrideChars.length);
1414
1495
  this._logger.debug("TextRenderer", `r_t-value has fewer characters (${overrideChars.length}) than nodes (${nodes.length}), only rendering first ${overrideChars.length} characters`);
1496
+ nodes = nodes.slice(0, overrideChars.length);
1415
1497
  }
1416
1498
  }
1417
1499
  // 1. Calculate RBR (or use displaced span if specified)
@@ -1425,6 +1507,7 @@ class TextRenderer {
1425
1507
  }
1426
1508
  if (rbrs.length === 0) {
1427
1509
  this._logger.warn("No RBRs calculated for additional text, skipping");
1510
+ this._logger.timeEnd(`renderAdditionalText-${versionTag}`);
1428
1511
  return;
1429
1512
  }
1430
1513
  // Use first RBR for positioning (additional text doesn't repeat per RBR)
@@ -1465,7 +1548,17 @@ class TextRenderer {
1465
1548
  backColor: config.backColor,
1466
1549
  italic: config.italic,
1467
1550
  bold: config.bold,
1551
+ underline: config.underline,
1552
+ overline: config.overline,
1553
+ strike: config.strike,
1554
+ textLineStyle: config.textLineStyle,
1555
+ textLineColor: config.textLineColor,
1468
1556
  });
1557
+ // Per-character rotation (r_rotate) is a size transform: apply it
1558
+ // before measuring so the EBR accounts for the rotated glyphs.
1559
+ if (config.rotate) {
1560
+ textEl.setAttribute("transform", `rotate(${config.rotate}, ${pos.x}, ${pos.y})`);
1561
+ }
1469
1562
  tempGroup.appendChild(textEl);
1470
1563
  measuredElements.push({ el: textEl, node, initX: pos.x, initY: pos.y });
1471
1564
  }
@@ -1522,11 +1615,22 @@ class TextRenderer {
1522
1615
  : undefined;
1523
1616
  // 9. Move each element to its final position and add to the SVG
1524
1617
  for (const { el, node, initX, initY } of measuredElements) {
1525
- el.setAttribute("x", String(initX + dx));
1526
- el.setAttribute("y", String(initY + dy));
1618
+ const finalX = initX + dx;
1619
+ const finalY = initY + dy;
1620
+ el.setAttribute("x", String(finalX));
1621
+ el.setAttribute("y", String(finalY));
1622
+ // Re-anchor the per-character rotation to the moved position, or the
1623
+ // glyph would rotate around the pre-translation measuring anchor.
1624
+ if (config.rotate) {
1625
+ el.setAttribute("transform", `rotate(${config.rotate}, ${finalX}, ${finalY})`);
1626
+ }
1527
1627
  rootSvg.appendChild(el);
1628
+ // getBBox() ignores the element's own transform, so for rotated
1629
+ // characters the cached rect is recomputed around the anchor.
1528
1630
  const bbox = getSafeBBox(el);
1529
- this._boundsCache.set(`n_${node.id}`, bbox);
1631
+ this._boundsCache.set(`n_${node.id}`, config.rotate
1632
+ ? rotateRectAroundPoint(bbox, config.rotate, finalX, finalY)
1633
+ : bbox);
1530
1634
  if (animationFn) {
1531
1635
  el.style.opacity = "0";
1532
1636
  await this._animationEngine.animate(el, animationFn, rootSvg);
@@ -1543,23 +1647,34 @@ class TextRenderer {
1543
1647
  calculateAdditionalTextPositions(nodes, baseX, baseY, config) {
1544
1648
  const positions = [];
1545
1649
  let currentX = baseX;
1546
- const currentY = baseY;
1650
+ let currentY = baseY;
1651
+ let lineNumber = 0;
1547
1652
  const fontSize = config?.fontSize ?? this._settings.fontSize;
1548
1653
  const fontFamily = config?.fontFamily ?? this._settings.fontFamily;
1549
1654
  const bold = config?.bold ?? this._settings.bold;
1550
1655
  const italic = config?.italic ?? this._settings.italic;
1656
+ // Space width follows the same rule as base text: a fraction
1657
+ // (spaceWidthFraction) of the reference character's width in the
1658
+ // effective font, not of the font size.
1659
+ const spaceWidth = getAverageCharWidth(fontFamily, fontSize, bold, italic, this._measurementRoot) *
1660
+ this._settings.spaceWidthFraction;
1551
1661
  for (let i = 0; i < nodes.length; i++) {
1552
1662
  const node = nodes[i];
1553
1663
  if (isLineBreak(node)) {
1554
- positions.push({ x: currentX, y: currentY, nodeId: node.id, lineNumber: 0 });
1664
+ // Line break: start a new line, mirroring base text layout. Uses the
1665
+ // effective font size so smaller/larger added text wraps accordingly.
1666
+ currentX = baseX;
1667
+ currentY +=
1668
+ fontSize * this._settings.lineHeight + this._settings.lineSpacing;
1669
+ lineNumber++;
1670
+ positions.push({ x: currentX, y: currentY, nodeId: node.id, lineNumber });
1555
1671
  }
1556
1672
  else if (isSpace(node)) {
1557
- const spaceWidth = fontSize * 0.33;
1558
- positions.push({ x: currentX, y: currentY, nodeId: node.id, lineNumber: 0 });
1673
+ positions.push({ x: currentX, y: currentY, nodeId: node.id, lineNumber });
1559
1674
  currentX += spaceWidth + this._settings.charSpacing;
1560
1675
  }
1561
1676
  else {
1562
- positions.push({ x: currentX, y: currentY, nodeId: node.id, lineNumber: 0 });
1677
+ positions.push({ x: currentX, y: currentY, nodeId: node.id, lineNumber });
1563
1678
  const charWidth = getTextWidth(node.data, fontFamily, fontSize, bold, italic, this._measurementRoot);
1564
1679
  currentX += charWidth + this._settings.charSpacing;
1565
1680
  }
@@ -1625,8 +1740,15 @@ class TextRenderer {
1625
1740
  if (nodes.length === 0)
1626
1741
  return [];
1627
1742
  const rbrs = [];
1628
- const nodeElementIds = nodes.map((n) => `n_${n.id}`);
1629
1743
  // Group nodes by line by checking their Y coordinates.
1744
+ // IMPORTANT: spaces and line breaks are skipped, exactly as in
1745
+ // HintRenderer.calculateRBRs. They have no SVG element, so the baseline
1746
+ // read below would fall back to their cached synthetic bounds top
1747
+ // (baseline - fontSize), which differs from neighboring characters'
1748
+ // baseline by ~fontSize — far beyond the 5px tolerance — falsely
1749
+ // splitting a single line into one group per word. Interior spaces
1750
+ // don't affect the union bounds anyway, since the flanking characters
1751
+ // cover their x-range.
1630
1752
  // IMPORTANT: this compares the character's laid-out baseline Y (the `y`
1631
1753
  // attribute TextLayout assigned when positioning it), NOT its cached
1632
1754
  // getBBox().y. The tight ink bbox top varies per glyph shape (ascenders,
@@ -1636,7 +1758,11 @@ class TextRenderer {
1636
1758
  const lineGroups = [];
1637
1759
  let currentLineGroup = [];
1638
1760
  let lastBaselineY = null;
1639
- for (const elementId of nodeElementIds) {
1761
+ for (const node of nodes) {
1762
+ if (isSpace(node) || isLineBreak(node)) {
1763
+ continue;
1764
+ }
1765
+ const elementId = `n_${node.id}`;
1640
1766
  const bounds = this._boundsCache.get(elementId);
1641
1767
  if (!bounds) {
1642
1768
  this._logger.warn(`Bounds not found for node element ${elementId}`);
@@ -1659,6 +1785,17 @@ class TextRenderer {
1659
1785
  if (currentLineGroup.length > 0) {
1660
1786
  lineGroups.push(currentLineGroup);
1661
1787
  }
1788
+ // Fallback: if every reference node is invisible (e.g. the operation's
1789
+ // reference span is a lone space), use the cached synthetic bounds of
1790
+ // all nodes as a single group so the added text still gets an RBR.
1791
+ if (lineGroups.length === 0) {
1792
+ const cachedIds = nodes
1793
+ .map((n) => `n_${n.id}`)
1794
+ .filter((id) => this._boundsCache.get(id) !== undefined);
1795
+ if (cachedIds.length > 0) {
1796
+ lineGroups.push(cachedIds);
1797
+ }
1798
+ }
1662
1799
  // Calculate RBR for each line group
1663
1800
  for (const group of lineGroups) {
1664
1801
  const rbr = this._boundsCache.getUnionBounds(group);
@@ -2241,6 +2378,8 @@ class FeatureResolver {
2241
2378
  underline: baseSettings.underline,
2242
2379
  overline: baseSettings.overline,
2243
2380
  strike: baseSettings.strike,
2381
+ textLineStyle: baseSettings.textLineStyle,
2382
+ textLineColor: baseSettings.textLineColor,
2244
2383
  textOffsetX: 0,
2245
2384
  textOffsetY: 0,
2246
2385
  };
@@ -2423,9 +2562,13 @@ class FeatureResolver {
2423
2562
  return;
2424
2563
  }
2425
2564
  }
2426
- // If no hints defined yet in config, we'll still store the override.
2427
- // It will be applied when hints are processed.
2428
- const hints = targetHints ?? (config.hints ?? ["*"]);
2565
+ // Untargeted overrides (no "@" prefix) are always stored under the "*"
2566
+ // wildcard key. Storing them under the hint keys from config.hints would
2567
+ // (a) depend on whether r_hints was parsed before this feature, and
2568
+ // (b) give them key-level priority, letting them overwrite an explicit
2569
+ // "@key:" override for the same property — the documented precedence is
2570
+ // ordinal > key > wildcard (see applyHintOverrides in HintRenderer).
2571
+ const hints = targetHints ?? ["*"];
2429
2572
  for (const hintKey of hints) {
2430
2573
  // Get or create override object for this hint key / ordinal
2431
2574
  let override = config.hintOverrides.get(hintKey);
@@ -25971,7 +26114,7 @@ class GveSnapshotRendition extends HTMLElement {
25971
26114
  * of the web component is loaded.
25972
26115
  */
25973
26116
  static get version() {
25974
- return "2.0.9";
26117
+ return "2.0.10";
25975
26118
  }
25976
26119
  constructor() {
25977
26120
  super();
@@ -26297,6 +26440,20 @@ class GveSnapshotRendition extends HTMLElement {
26297
26440
  * The SVG rendition panel has no header and acts as the fixed background.
26298
26441
  */
26299
26442
  initializeGoldenLayout() {
26443
+ // Guard against stale rAF callbacks: render() can run more than once
26444
+ // before the first scheduled callback fires (e.g. a consumer sets
26445
+ // settings/data before the element is connected, then connectedCallback
26446
+ // renders again). Without these checks two GoldenLayout instances would
26447
+ // be created on the same container, duplicating panels and leaking the
26448
+ // first instance's ResizeObserver.
26449
+ if (!this.isConnected) {
26450
+ this._logger.debug("GL", "Skipping GL init - component not connected");
26451
+ return;
26452
+ }
26453
+ if (this._goldenLayout) {
26454
+ this._goldenLayout.destroy();
26455
+ this._goldenLayout = undefined;
26456
+ }
26300
26457
  const glContainer = this._shadow.getElementById("gl-container");
26301
26458
  if (!glContainer) {
26302
26459
  this._logger.error("GL", "gl-container not found in Shadow DOM");
@@ -26368,6 +26525,18 @@ class GveSnapshotRendition extends HTMLElement {
26368
26525
  showPopoutIcon: false,
26369
26526
  showMaximiseIcon: false,
26370
26527
  showCloseIcon: false,
26528
+ // Tab dragging/docking is disabled: the four panels are fixed
26529
+ // functional areas, and users only need the splitters to
26530
+ // redistribute space. This prevents dragging a panel into another
26531
+ // stack (or into limbo) and losing the intended layout. The
26532
+ // "reset layout" toolbar button remains the escape hatch.
26533
+ reorderEnabled: false,
26534
+ },
26535
+ dimensions: {
26536
+ // Prevent panels from being collapsed to zero size via the
26537
+ // splitters, which would make them hard to grab back.
26538
+ defaultMinItemWidth: "80px",
26539
+ defaultMinItemHeight: "60px",
26371
26540
  },
26372
26541
  root: {
26373
26542
  type: "column",
@@ -26569,12 +26738,13 @@ class GveSnapshotRendition extends HTMLElement {
26569
26738
  if (refNodes.length === 0) {
26570
26739
  this._logger.warn(`No reference nodes found for operation ${operation.id}`);
26571
26740
  // Still fire event even if no reference nodes
26741
+ const previousVersion = this._currentVersion;
26572
26742
  this._currentVersion = versionTag;
26573
26743
  // Extract version index from tag (e.g., "v2" → 2)
26574
26744
  const versionMatch = versionTag.match(/v(\d+)/);
26575
26745
  this._currentVersionIndex = versionMatch ? parseInt(versionMatch[1]) : 0;
26576
26746
  this.dispatchEvent(new CustomEvent("versionTagChange", {
26577
- detail: { from: this._currentVersion, to: versionTag },
26747
+ detail: { from: previousVersion, to: versionTag },
26578
26748
  }));
26579
26749
  return;
26580
26750
  }
@@ -40952,7 +41122,7 @@ function requireD () {
40952
41122
  + 'pragma private protected public pure ref return scope shared static struct '
40953
41123
  + 'super switch synchronized template this throw try typedef typeid typeof union '
40954
41124
  + 'unittest version void volatile while with __FILE__ __LINE__ __gshared|10 '
40955
- + '__thread __traits __DATE__ __EOF__ __TIME__ __TIMESTAMP__ __VENDOR__ 2.0.9',
41125
+ + '__thread __traits __DATE__ __EOF__ __TIME__ __TIMESTAMP__ __VENDOR__ 2.0.10',
40956
41126
  built_in:
40957
41127
  'bool cdouble cent cfloat char creal dchar delegate double dstring float function '
40958
41128
  + 'idouble ifloat ireal long real short string ubyte ucent uint ulong ushort wchar '
@@ -90566,23 +90736,36 @@ class GveHintDesigner extends HTMLElement {
90566
90736
  this._isPlaying = false;
90567
90737
  // Create logger
90568
90738
  this._logger = new Logger("GVE-HintDesigner", this._settings.debug);
90569
- // Register GSAP plugins
90739
+ this.registerGsapPlugins();
90740
+ // Create shadow DOM
90741
+ this._shadow = this.attachShadow({ mode: "open" });
90742
+ this._logger.info("GveHintDesigner component created");
90743
+ }
90744
+ /**
90745
+ * Register GSAP plugins when GSAP is available. Retried in
90746
+ * connectedCallback because GSAP may be loaded after the component
90747
+ * class is constructed.
90748
+ */
90749
+ registerGsapPlugins() {
90570
90750
  const gsap = window.gsap;
90571
- if (gsap) {
90751
+ if (gsap && !gsap.plugins?.drawSVG) {
90572
90752
  gsap.registerPlugin(DrawSVGPlugin);
90573
90753
  this._logger.debug("Component", "DrawSVG plugin registered:", gsap.plugins.drawSVG !== undefined);
90574
90754
  }
90575
- // Create shadow DOM
90576
- this._shadow = this.attachShadow({ mode: "open" });
90577
- this._logger.info("GveHintDesigner component created");
90578
90755
  }
90579
90756
  /**
90580
90757
  * Called when component is added to the DOM.
90581
90758
  */
90582
90759
  connectedCallback() {
90583
90760
  this._logger.info("Component connected to DOM");
90761
+ this.registerGsapPlugins();
90584
90762
  this.render();
90585
90763
  this.initializeEventListeners();
90764
+ // Reflect any state assigned before the element was connected (a common
90765
+ // pattern in vanilla consumers: set data/settings/hintId first, append
90766
+ // later). render() builds empty dropdowns/tables, so populate them now.
90767
+ this.refreshUI();
90768
+ this.refreshVariablesTable();
90586
90769
  }
90587
90770
  /**
90588
90771
  * Called when component is removed from the DOM.
@@ -90644,9 +90827,10 @@ class GveHintDesigner extends HTMLElement {
90644
90827
  this._hintVariables = value || [];
90645
90828
  }
90646
90829
  this.refreshVariablesTable();
90647
- this.fireEvent("hintVariablesChange", {
90648
- hintVariables: this._hintVariables,
90649
- });
90830
+ // NOTE: no hintVariablesChange event here. Change events represent USER
90831
+ // edits (add/edit/delete/populate, which fire it themselves); firing from
90832
+ // the setter would echo programmatic assignments back to the consumer,
90833
+ // which can loop forever with two-way bindings that clone the array.
90650
90834
  }
90651
90835
  // ==================== RENDERING ====================
90652
90836
  /**
@@ -91261,7 +91445,11 @@ class GveHintDesigner extends HTMLElement {
91261
91445
  this._lastPanY = mouseEvent.clientY;
91262
91446
  panel.style.cursor = "grabbing";
91263
91447
  });
91264
- document.addEventListener("mousemove", (e) => {
91448
+ // Remove any handlers from a previous connect cycle before adding new
91449
+ // ones: connectedCallback re-runs initializeEventListeners each time the
91450
+ // element is (re)attached, and document-level listeners would accumulate.
91451
+ this.removeDocumentPanListeners();
91452
+ this._panMoveHandler = (e) => {
91265
91453
  if (!this._isPanning)
91266
91454
  return;
91267
91455
  const deltaX = e.clientX - this._lastPanX;
@@ -91271,17 +91459,34 @@ class GveHintDesigner extends HTMLElement {
91271
91459
  this._lastPanX = e.clientX;
91272
91460
  this._lastPanY = e.clientY;
91273
91461
  this.updateSvgTransform();
91274
- });
91275
- document.addEventListener("mouseup", () => {
91462
+ };
91463
+ document.addEventListener("mousemove", this._panMoveHandler);
91464
+ this._panUpHandler = () => {
91276
91465
  if (this._isPanning) {
91277
91466
  this._isPanning = false;
91278
- const panelEl = panel;
91279
- panelEl.style.cursor = "grab";
91467
+ const panelEl = this._shadow.querySelector(".svg-display-panel");
91468
+ if (panelEl) {
91469
+ panelEl.style.cursor = "grab";
91470
+ }
91280
91471
  }
91281
- });
91472
+ };
91473
+ document.addEventListener("mouseup", this._panUpHandler);
91282
91474
  // Set initial cursor
91283
91475
  panel.style.cursor = "grab";
91284
91476
  }
91477
+ /**
91478
+ * Remove the document-level pan listeners, if any.
91479
+ */
91480
+ removeDocumentPanListeners() {
91481
+ if (this._panMoveHandler) {
91482
+ document.removeEventListener("mousemove", this._panMoveHandler);
91483
+ this._panMoveHandler = undefined;
91484
+ }
91485
+ if (this._panUpHandler) {
91486
+ document.removeEventListener("mouseup", this._panUpHandler);
91487
+ this._panUpHandler = undefined;
91488
+ }
91489
+ }
91285
91490
  /**
91286
91491
  * Initialize zooming with mouse wheel.
91287
91492
  */
@@ -91325,6 +91530,8 @@ class GveHintDesigner extends HTMLElement {
91325
91530
  clearInterval(this._progressUpdateInterval);
91326
91531
  this._progressUpdateInterval = undefined;
91327
91532
  }
91533
+ this.removeDocumentPanListeners();
91534
+ this._isPanning = false;
91328
91535
  }
91329
91536
  // ==================== UI ACTIONS ====================
91330
91537
  /**
@@ -91693,13 +91900,14 @@ class GveHintDesigner extends HTMLElement {
91693
91900
  const confirm = window.confirm(`Delete hint "${this._hintId}"?`);
91694
91901
  if (!confirm)
91695
91902
  return;
91696
- delete this._data.hints[this._hintId];
91903
+ const deletedId = this._hintId;
91904
+ delete this._data.hints[deletedId];
91697
91905
  this.hintId = undefined;
91698
91906
  this.refreshHintsDropdown();
91699
91907
  this.clearHintForm();
91700
91908
  this.showMessage("Hint deleted", "success");
91701
91909
  this.fireEvent("dataChange", { data: this._data });
91702
- this._logger.info("Deleted hint", this._hintId);
91910
+ this._logger.info("Deleted hint", deletedId);
91703
91911
  }
91704
91912
  /**
91705
91913
  * Parse offset value (number or string with suffix).
@@ -91795,26 +92003,29 @@ class GveHintDesigner extends HTMLElement {
91795
92003
  }
91796
92004
  /**
91797
92005
  * Resolve variables in SVG code.
92006
+ * Delegates to the same resolver used by the renderer (shared semantics:
92007
+ * literal names — no regex injection — and "??" fallback chains like
92008
+ * {{r_fore-color ?? r_color}}).
91798
92009
  */
91799
92010
  resolveVariables(svgCode) {
91800
- let resolved = svgCode;
91801
- this._hintVariables.forEach((variable) => {
91802
- const placeholder = `{{${variable.name}}}`;
91803
- resolved = resolved.replace(new RegExp(placeholder, "g"), variable.value);
91804
- });
91805
- return resolved;
92011
+ const variables = new Map(this._hintVariables.map((v) => [v.name, v.value]));
92012
+ return resolvePlaceholders(svgCode, variables);
91806
92013
  }
91807
92014
  /**
91808
92015
  * Extract variable names from SVG code (variables in {{...}} placeholders).
92016
+ * Placeholders may contain "??" fallback chains: each name in a chain is
92017
+ * reported as a separate variable.
91809
92018
  */
91810
92019
  extractVariablesFromSvg(svgCode) {
91811
92020
  const variablePattern = /\{\{([^}]+)\}\}/g;
91812
92021
  const variables = [];
91813
92022
  let match;
91814
92023
  while ((match = variablePattern.exec(svgCode)) !== null) {
91815
- const varName = match[1].trim();
91816
- if (varName && !variables.includes(varName)) {
91817
- variables.push(varName);
92024
+ for (const name of match[1].split("??")) {
92025
+ const varName = name.trim();
92026
+ if (varName && !variables.includes(varName)) {
92027
+ variables.push(varName);
92028
+ }
91818
92029
  }
91819
92030
  }
91820
92031
  return variables;