@myrmidon/gve-snapshot-rendition 1.0.2 → 2.0.2

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
@@ -71,12 +71,10 @@ const DEFAULT_SETTINGS = {
71
71
  `,
72
72
  },
73
73
  charAnimationId: undefined, // No character animation by default
74
- spreadTime: 1500,
75
74
  backwardFadeOutTime: 300,
76
75
  prologDuration: 800,
77
76
  // Hints - empty by default, to be filled by consumer code
78
77
  hints: {},
79
- hintMargin: 0,
80
78
  hintDesignWidth: 300,
81
79
  hintDesignHeight: 100,
82
80
  showHintHandles: false,
@@ -290,54 +288,6 @@ class AnimationEngine {
290
288
  this._logger.error("Animation execution error:", error);
291
289
  }
292
290
  }
293
- /**
294
- * Animate multiple elements with spreading transitions.
295
- * All elements are animated in parallel with GSAP.
296
- *
297
- * @param shifts - Map of element IDs to their shift amounts {x, y}
298
- * @param duration - Duration in milliseconds
299
- * @param rootElement - The root SVG element containing all elements
300
- * @returns Promise that resolves when all animations complete
301
- */
302
- async animateSpreading(shifts, duration, rootElement) {
303
- if (shifts.size === 0) {
304
- this._logger.debug("AnimationEngine", "No elements to spread");
305
- return;
306
- }
307
- this._logger.debug("AnimationEngine", `Animating spreading for ${shifts.size} elements`);
308
- this._logger.time("animateSpreading");
309
- const timeline = this._gsap.timeline();
310
- for (const [elementId, shift] of shifts.entries()) {
311
- const element = rootElement.querySelector(`#${elementId}`);
312
- if (!element) {
313
- this._logger.warn(`Element ${elementId} not found for spreading animation`);
314
- continue;
315
- }
316
- // Get current transform
317
- const currentTransform = element.getAttribute("transform") || "";
318
- const translateMatch = currentTransform.match(/translate\(([-\d.]+),\s*([-\d.]+)\)/);
319
- let currentX = 0;
320
- let currentY = 0;
321
- if (translateMatch) {
322
- currentX = parseFloat(translateMatch[1]);
323
- currentY = parseFloat(translateMatch[2]);
324
- }
325
- const newX = currentX + shift.x;
326
- const newY = currentY + shift.y;
327
- // Add to timeline (animations run in parallel with position 0)
328
- timeline.to(element, {
329
- attr: { transform: `translate(${newX}, ${newY})` },
330
- duration: duration / 1000, // Convert ms to seconds for GSAP
331
- ease: "power2.inOut",
332
- }, 0 // Start time: 0 means all animations start together
333
- );
334
- }
335
- // Wait for timeline to complete
336
- await new Promise((resolve) => {
337
- timeline.eventCallback("onComplete", resolve);
338
- });
339
- this._logger.timeEnd("animateSpreading");
340
- }
341
291
  /**
342
292
  * Fade in multiple elements (used for hilites).
343
293
  *
@@ -654,271 +604,6 @@ class BoundsCache {
654
604
  }
655
605
  }
656
606
 
657
- /**
658
- * Engine for calculating spreading (making room for new elements).
659
- * Implements the "stone in pond" ripple effect to preserve layout.
660
- */
661
- class SpreadingEngine {
662
- constructor(boundsCache, logger) {
663
- /**
664
- * Map of element ID to array of spreading metadata (one per version).
665
- * Each element can be shifted multiple times by different versions.
666
- */
667
- this._spreadingHistory = new Map();
668
- this._boundsCache = boundsCache;
669
- this._logger = logger;
670
- }
671
- /**
672
- * Track spreading metadata for elements affected by a version.
673
- * Called after spreading is applied to record what happened.
674
- *
675
- * @param versionIndex - Version index that caused the spreading
676
- * @param horizontalShifts - Map of element IDs to horizontal shifts
677
- * @param verticalShifts - Map of element IDs to vertical shifts
678
- */
679
- trackSpreading(versionIndex, horizontalShifts, verticalShifts) {
680
- // Collect all affected element IDs
681
- const affectedIds = new Set([
682
- ...horizontalShifts.keys(),
683
- ...verticalShifts.keys(),
684
- ]);
685
- for (const elementId of affectedIds) {
686
- const hShift = horizontalShifts.get(elementId) || 0;
687
- const vShift = verticalShifts.get(elementId) || 0;
688
- const metadata = {
689
- versionIndex,
690
- horizontalShift: hShift,
691
- verticalShift: vShift,
692
- };
693
- // Get or create history array for this element
694
- let history = this._spreadingHistory.get(elementId);
695
- if (!history) {
696
- history = [];
697
- this._spreadingHistory.set(elementId, history);
698
- }
699
- // Add to history
700
- history.push(metadata);
701
- this._logger.debug("Spreading", `Tracked spreading for ${elementId} at v${versionIndex}: h=${hShift}, v=${vShift}`);
702
- }
703
- }
704
- /**
705
- * Get spreading metadata for elements affected by a specific version.
706
- * Used when reversing spreading during backward navigation.
707
- *
708
- * @param versionIndex - Version index to get spreading for
709
- * @returns Map of element ID to spreading metadata
710
- */
711
- getSpreadingForVersion(versionIndex) {
712
- const result = new Map();
713
- for (const [elementId, history] of this._spreadingHistory.entries()) {
714
- // Find spreading caused by this version
715
- const metadata = history.find((m) => m.versionIndex === versionIndex);
716
- if (metadata) {
717
- result.set(elementId, metadata);
718
- }
719
- }
720
- return result;
721
- }
722
- /**
723
- * Remove spreading history for a specific version.
724
- * Called after spreading is reversed during backward navigation.
725
- *
726
- * @param versionIndex - Version index to remove history for
727
- */
728
- clearSpreadingForVersion(versionIndex) {
729
- for (const [elementId, history] of this._spreadingHistory.entries()) {
730
- // Remove entries for this version
731
- const filtered = history.filter((m) => m.versionIndex !== versionIndex);
732
- if (filtered.length === 0) {
733
- // No more history for this element
734
- this._spreadingHistory.delete(elementId);
735
- }
736
- else {
737
- this._spreadingHistory.set(elementId, filtered);
738
- }
739
- }
740
- this._logger.debug("Spreading", `Cleared spreading history for v${versionIndex}`);
741
- }
742
- /**
743
- * Calculate spreading required to make room for a new element.
744
- *
745
- * @param newElementBounds - Bounds of the element to be added
746
- * @param excludeIds - Element IDs to exclude from shifting (e.g., the new element itself)
747
- * @returns Spreading result with shift amounts for each affected element
748
- */
749
- calculateSpreading(newElementBounds, excludeIds = []) {
750
- this._logger.time("calculateSpreading");
751
- this._logger.debug("Spreading", "Calculating spreading for new element", newElementBounds);
752
- const horizontalShifts = new Map();
753
- const verticalShifts = new Map();
754
- // Get all existing elements
755
- const allIds = this._boundsCache
756
- .getAllIds()
757
- .filter((id) => !excludeIds.includes(id));
758
- // Calculate horizontal spreading
759
- const hShifts = this.calculateHorizontalSpreading(newElementBounds, allIds);
760
- hShifts.forEach((shift, id) => horizontalShifts.set(id, shift));
761
- // Calculate vertical spreading
762
- const vShifts = this.calculateVerticalSpreading(newElementBounds, allIds);
763
- vShifts.forEach((shift, id) => verticalShifts.set(id, shift));
764
- const hasShifts = horizontalShifts.size > 0 || verticalShifts.size > 0;
765
- this._logger.debug("Spreading", "Spreading calculation complete", {
766
- horizontalShifts: horizontalShifts.size,
767
- verticalShifts: verticalShifts.size,
768
- hasShifts,
769
- });
770
- this._logger.timeEnd("calculateSpreading");
771
- return { horizontalShifts, verticalShifts, hasShifts };
772
- }
773
- /**
774
- * Calculate horizontal spreading.
775
- * Elements overlapping on the left are shifted left, those on the right are shifted right.
776
- */
777
- calculateHorizontalSpreading(newBounds, elementIds) {
778
- const shifts = new Map();
779
- let maxLeftOverlap = 0;
780
- let maxRightOverlap = 0;
781
- // Find all overlaps
782
- const leftElements = [];
783
- const rightElements = [];
784
- for (const id of elementIds) {
785
- const bounds = this._boundsCache.get(id);
786
- if (!bounds)
787
- continue;
788
- // Check for horizontal overlap
789
- const horizontalOverlap = this.calculateHorizontalOverlap(newBounds, bounds);
790
- if (horizontalOverlap > 0) {
791
- // Determine which side this element is on
792
- if (bounds.right > newBounds.x && bounds.x < newBounds.x) {
793
- // Element overlaps from the left
794
- leftElements.push(id);
795
- maxLeftOverlap = Math.max(maxLeftOverlap, horizontalOverlap);
796
- }
797
- else if (bounds.x < newBounds.right &&
798
- bounds.right > newBounds.right) {
799
- // Element overlaps from the right
800
- rightElements.push(id);
801
- maxRightOverlap = Math.max(maxRightOverlap, horizontalOverlap);
802
- }
803
- }
804
- }
805
- // Apply shifts
806
- if (maxLeftOverlap > 0) {
807
- for (const id of leftElements) {
808
- shifts.set(id, -maxLeftOverlap); // Shift left (negative)
809
- this._logger.debug("Spreading", `Element ${id} shifts left by ${maxLeftOverlap}px`);
810
- }
811
- }
812
- if (maxRightOverlap > 0) {
813
- for (const id of rightElements) {
814
- shifts.set(id, maxRightOverlap); // Shift right (positive)
815
- this._logger.debug("Spreading", `Element ${id} shifts right by ${maxRightOverlap}px`);
816
- }
817
- }
818
- return shifts;
819
- }
820
- /**
821
- * Calculate vertical spreading.
822
- * Elements overlapping from above are shifted up, those from below are shifted down.
823
- */
824
- calculateVerticalSpreading(newBounds, elementIds) {
825
- const shifts = new Map();
826
- let maxTopOverlap = 0;
827
- let maxBottomOverlap = 0;
828
- // Find all overlaps
829
- const topElements = [];
830
- const bottomElements = [];
831
- for (const id of elementIds) {
832
- const bounds = this._boundsCache.get(id);
833
- if (!bounds)
834
- continue;
835
- // Check for vertical overlap
836
- const verticalOverlap = this.calculateVerticalOverlap(newBounds, bounds);
837
- if (verticalOverlap > 0) {
838
- // Determine which side this element is on
839
- if (bounds.bottom > newBounds.y && bounds.y < newBounds.y) {
840
- // Element overlaps from above
841
- topElements.push(id);
842
- maxTopOverlap = Math.max(maxTopOverlap, verticalOverlap);
843
- }
844
- else if (bounds.y < newBounds.bottom &&
845
- bounds.bottom > newBounds.bottom) {
846
- // Element overlaps from below
847
- bottomElements.push(id);
848
- maxBottomOverlap = Math.max(maxBottomOverlap, verticalOverlap);
849
- }
850
- }
851
- }
852
- // Apply shifts
853
- if (maxTopOverlap > 0) {
854
- for (const id of topElements) {
855
- shifts.set(id, -maxTopOverlap); // Shift up (negative)
856
- this._logger.debug("Spreading", `Element ${id} shifts up by ${maxTopOverlap}px`);
857
- }
858
- }
859
- if (maxBottomOverlap > 0) {
860
- for (const id of bottomElements) {
861
- shifts.set(id, maxBottomOverlap); // Shift down (positive)
862
- this._logger.debug("Spreading", `Element ${id} shifts down by ${maxBottomOverlap}px`);
863
- }
864
- }
865
- return shifts;
866
- }
867
- /**
868
- * Calculate the horizontal overlap between two bounding rectangles.
869
- * Returns 0 if there is no overlap.
870
- */
871
- calculateHorizontalOverlap(rect1, rect2) {
872
- // Check if rectangles overlap horizontally
873
- const overlapLeft = Math.max(rect1.x, rect2.x);
874
- const overlapRight = Math.min(rect1.right, rect2.right);
875
- if (overlapLeft < overlapRight) {
876
- return overlapRight - overlapLeft;
877
- }
878
- return 0;
879
- }
880
- /**
881
- * Calculate the vertical overlap between two bounding rectangles.
882
- * Returns 0 if there is no overlap.
883
- */
884
- calculateVerticalOverlap(rect1, rect2) {
885
- // Check if rectangles overlap vertically
886
- const overlapTop = Math.max(rect1.y, rect2.y);
887
- const overlapBottom = Math.min(rect1.bottom, rect2.bottom);
888
- if (overlapTop < overlapBottom) {
889
- return overlapBottom - overlapTop;
890
- }
891
- return 0;
892
- }
893
- /**
894
- * Check if two bounding rectangles overlap.
895
- */
896
- checkOverlap(rect1, rect2) {
897
- return !((rect1.right <= rect2.x || // rect1 is to the left of rect2
898
- rect1.x >= rect2.right || // rect1 is to the right of rect2
899
- rect1.bottom <= rect2.y || // rect1 is above rect2
900
- rect1.y >= rect2.bottom) // rect1 is below rect2
901
- );
902
- }
903
- /**
904
- * Find all elements that overlap with given bounds.
905
- */
906
- findOverlappingElements(bounds, excludeIds = []) {
907
- const overlapping = [];
908
- for (const id of this._boundsCache.getAllIds()) {
909
- if (excludeIds.includes(id))
910
- continue;
911
- const elementBounds = this._boundsCache.get(id);
912
- if (!elementBounds)
913
- continue;
914
- if (this.checkOverlap(bounds, elementBounds)) {
915
- overlapping.push(id);
916
- }
917
- }
918
- return overlapping;
919
- }
920
- }
921
-
922
607
  /**
923
608
  * Utility functions for working with character nodes.
924
609
  */
@@ -1560,273 +1245,16 @@ class TextLayout {
1560
1245
  }
1561
1246
  }
1562
1247
 
1563
- /**
1564
- * Feature resolver converts features into rendition configuration.
1565
- * This handles all the r_* rendition features and applies them to create
1566
- * a complete configuration for rendering.
1567
- */
1568
- class FeatureResolver {
1569
- constructor(logger) {
1570
- this._logger = logger;
1571
- }
1572
- /**
1573
- * Resolve rendition features into a complete configuration.
1574
- * Features override the base settings.
1575
- *
1576
- * @param baseSettings - The base settings from component
1577
- * @param features - Array of features to apply
1578
- * @param nodeFeatureContext - Optional context for extracting node features for placeholder resolution
1579
- * @returns Resolved rendition configuration
1580
- */
1581
- resolve(baseSettings, features, nodeFeatureContext) {
1582
- const config = {
1583
- fontSize: baseSettings.fontSize,
1584
- fontFamily: baseSettings.fontFamily,
1585
- foreColor: baseSettings.foreColor,
1586
- backColor: baseSettings.backColor,
1587
- italic: baseSettings.italic,
1588
- bold: baseSettings.bold,
1589
- underline: baseSettings.underline,
1590
- overline: baseSettings.overline,
1591
- strike: baseSettings.strike,
1592
- textOffsetX: 0,
1593
- textOffsetY: 0,
1594
- };
1595
- if (!features || features.length === 0) {
1596
- return config;
1597
- }
1598
- // Initialize hintVars map - will contain ALL feature values for placeholder
1599
- // replacement
1600
- config.hintVars = new Map();
1601
- // Initialize hintOverrides map for r_h-* features
1602
- config.hintOverrides = new Map();
1603
- // Extract ALL features from reference nodes for placeholder resolution.
1604
- // This includes features like "note", "group", etc. that don't have r_ prefix.
1605
- // Features are now stored per-node keyed by version tag.
1606
- if (nodeFeatureContext) {
1607
- const { refNodes, outputTag } = nodeFeatureContext;
1608
- for (const node of refNodes) {
1609
- const nodeFeaturesArray = node.features?.[outputTag];
1610
- if (!nodeFeaturesArray)
1611
- continue;
1612
- for (const nodeFeature of nodeFeaturesArray) {
1613
- if (nodeFeature.name.startsWith("$"))
1614
- continue;
1615
- if (!config.hintVars.has(nodeFeature.name)) {
1616
- config.hintVars.set(nodeFeature.name, nodeFeature.value);
1617
- this._logger.debug("FeatureResolver", `Added node feature to hintVars from node ${node.id} @ ${outputTag}: ${nodeFeature.name}=${nodeFeature.value}`);
1618
- }
1619
- }
1620
- }
1621
- }
1622
- // Process each rendition feature from operation
1623
- for (const feature of features) {
1624
- if (!feature.name.startsWith("r_"))
1625
- continue;
1626
- const value = feature.value;
1627
- // Add ALL r_* features to hintVars for placeholder replacement
1628
- // This allows {{r_fore-color}}, {{r_font-size}}, etc. in hint SVG templates
1629
- // These override any node features with the same name
1630
- config.hintVars.set(feature.name, value);
1631
- switch (feature.name) {
1632
- case "r_font-size":
1633
- config.fontSize = parseFloat(value);
1634
- break;
1635
- case "r_font-family":
1636
- config.fontFamily = value;
1637
- break;
1638
- case "r_fore-color":
1639
- config.foreColor = value;
1640
- break;
1641
- case "r_back-color":
1642
- config.backColor = value;
1643
- break;
1644
- case "r_italic":
1645
- config.italic = value === "true" || value === "1";
1646
- break;
1647
- case "r_bold":
1648
- config.bold = value === "true" || value === "1";
1649
- break;
1650
- case "r_underline":
1651
- config.underline = parseFloat(value);
1652
- break;
1653
- case "r_overline":
1654
- config.overline = parseFloat(value);
1655
- break;
1656
- case "r_strike":
1657
- config.strike = parseFloat(value);
1658
- break;
1659
- case "r_text-line-style":
1660
- config.textLineStyle = value;
1661
- break;
1662
- case "r_text-line-color":
1663
- config.textLineColor = value;
1664
- break;
1665
- case "r_rotate":
1666
- config.rotate = parseFloat(value);
1667
- break;
1668
- // r_t-* features for added text
1669
- case "r_t-position":
1670
- config.textPosition = value;
1671
- break;
1672
- case "r_t-offset-x":
1673
- // Store the value as-is (number or string). It will be parsed later
1674
- // in renderAdditionalText when RBR bounds are available.
1675
- config.textOffsetX = typeof value === "number" ? value : value;
1676
- break;
1677
- case "r_t-offset-y":
1678
- // Store the value as-is (number or string). It will be parsed later
1679
- // in renderAdditionalText when RBR bounds are available.
1680
- config.textOffsetY = typeof value === "number" ? value : value;
1681
- break;
1682
- case "r_t-solid":
1683
- config.textSolid = value === "1" || value === "true";
1684
- break;
1685
- case "r_t-displaced-span":
1686
- config.textDisplacedSpan = parseDisplacedSpan(value);
1687
- if (!config.textDisplacedSpan) {
1688
- this._logger.warn(`Invalid r_t-displaced-span value: ${value}`);
1689
- }
1690
- break;
1691
- case "r_t-value":
1692
- config.textValue = value;
1693
- break;
1694
- case "r_hints":
1695
- config.hints = value.trim().split(/\s+/);
1696
- break;
1697
- case "r_hint-vars":
1698
- // Parse custom variables and merge with existing hintVars
1699
- const customVars = parseHintVars(value);
1700
- customVars.forEach((val, key) => {
1701
- config.hintVars.set(key, val);
1702
- });
1703
- break;
1704
- // r_h-* features for hint property overrides
1705
- default:
1706
- if (feature.name.startsWith("r_h-")) {
1707
- this.parseHintOverride(feature.name, value, config);
1708
- }
1709
- break;
1710
- }
1711
- }
1712
- return config;
1713
- }
1714
- /**
1715
- * Extract character offsets from init features.
1716
- * This is used for base text layout adjustments.
1717
- *
1718
- * @param features - Features from the first annotate operation (init features)
1719
- * @returns Map of node IDs to their offsets (values may be numbers or "Ntw"/"Nth" strings)
1720
- */
1721
- extractCharOffsets(features) {
1722
- if (!features)
1723
- return new Map();
1724
- const charOffsetsValue = getFeatureValue(features, "r_char-offsets");
1725
- if (!charOffsetsValue)
1726
- return new Map();
1727
- return parseCharOffsets(charOffsetsValue);
1728
- }
1729
- /**
1730
- * Check if features contain a specific rendition feature.
1731
- */
1732
- hasRenditionFeature(features, featureName) {
1733
- if (!features)
1734
- return false;
1735
- return features.some((f) => f.name === featureName);
1736
- }
1737
- /**
1738
- * Get the value of a rendition feature.
1739
- */
1740
- getRenditionFeatureValue(features, featureName) {
1741
- return getFeatureValue(features, featureName);
1742
- }
1743
- /**
1744
- * Parse r_h-* hint override features.
1745
- * Format: "value" applies to all hints, or "@hintId1 hintId2:value" applies to specific hints.
1746
- *
1747
- * @param featureName - The feature name (e.g., "r_h-position")
1748
- * @param value - The feature value
1749
- * @param config - The rendition config to update
1750
- */
1751
- parseHintOverride(featureName, value, config) {
1752
- // Extract property name from feature name (r_h-position -> position)
1753
- const propertyName = featureName.substring(4); // Remove "r_h-"
1754
- // Parse value to check if it targets specific hints
1755
- let targetHints = null; // null = all hints
1756
- let actualValue = value;
1757
- if (value.startsWith("@")) {
1758
- // Format: "@hintId1 hintId2:value"
1759
- const colonIndex = value.indexOf(":");
1760
- if (colonIndex > 0) {
1761
- const hintIdsStr = value.substring(1, colonIndex); // Remove "@" and get up to ":"
1762
- targetHints = hintIdsStr.trim().split(/\s+/);
1763
- actualValue = value.substring(colonIndex + 1);
1764
- }
1765
- else {
1766
- this._logger.warn(`Invalid r_h-* format (missing colon): ${featureName}=${value}`);
1767
- return;
1768
- }
1769
- }
1770
- // If no hints defined yet in config, we'll still store the override
1771
- // It will be applied when hints are processed
1772
- const hints = targetHints || (config.hints ? config.hints : ["*"]);
1773
- for (const hintId of hints) {
1774
- // Get or create override object for this hint
1775
- let override = config.hintOverrides.get(hintId);
1776
- if (!override) {
1777
- override = {};
1778
- config.hintOverrides.set(hintId, override);
1779
- }
1780
- // Set the property value
1781
- switch (propertyName) {
1782
- case "position":
1783
- override.position = actualValue;
1784
- break;
1785
- case "offset-x":
1786
- override.offsetX = actualValue;
1787
- break;
1788
- case "offset-y":
1789
- override.offsetY = actualValue;
1790
- break;
1791
- case "scale-x":
1792
- override.scaleX = actualValue;
1793
- break;
1794
- case "scale-y":
1795
- override.scaleY = actualValue;
1796
- break;
1797
- case "rotation":
1798
- override.rotation = actualValue;
1799
- break;
1800
- case "solid":
1801
- override.solid = actualValue === "1" || actualValue === "true";
1802
- break;
1803
- case "displaced-span":
1804
- override.displacedSpan = parseDisplacedSpan(actualValue);
1805
- if (!override.displacedSpan) {
1806
- this._logger.warn(`Invalid r_h-displaced-span value: ${actualValue}`);
1807
- }
1808
- break;
1809
- default:
1810
- this._logger.warn(`Unknown hint override property: ${propertyName}`);
1811
- break;
1812
- }
1813
- this._logger.debug("FeatureResolver", `Added hint override for ${hintId}: ${propertyName}=${actualValue}`);
1814
- }
1815
- }
1816
- }
1817
-
1818
1248
  /**
1819
1249
  * Text renderer handles rendering of base text and additional text.
1820
1250
  */
1821
1251
  class TextRenderer {
1822
- constructor(settings, logger, animationEngine, boundsCache, spreadingEngine) {
1252
+ constructor(settings, logger, animationEngine, boundsCache) {
1823
1253
  this._settings = settings;
1824
1254
  this._logger = logger;
1825
1255
  this._animationEngine = animationEngine;
1826
1256
  this._boundsCache = boundsCache;
1827
- this._spreadingEngine = spreadingEngine;
1828
1257
  this._textLayout = new TextLayout(settings, logger);
1829
- this._featureResolver = new FeatureResolver(logger);
1830
1258
  }
1831
1259
  /**
1832
1260
  * Render base text (v0).
@@ -1957,7 +1385,7 @@ class TextRenderer {
1957
1385
  }
1958
1386
  // Apply r_t-value override if specified
1959
1387
  // This overrides the operation's value (the text being added) for display only
1960
- if (config.textValue) {
1388
+ if (config.textValue !== null && config.textValue !== undefined) {
1961
1389
  this._logger.debug("TextRenderer", `Applying r_t-value override: "${config.textValue}" (original text had ${nodes.length} characters)`);
1962
1390
  // Replace node data with characters from r_t-value
1963
1391
  // If r_t-value has fewer characters than nodes, we only override the first N nodes
@@ -1989,137 +1417,186 @@ class TextRenderer {
1989
1417
  // Use first RBR for positioning (additional text doesn't repeat per RBR)
1990
1418
  const rbr = rbrs[0];
1991
1419
  this._logger.debug("TextRenderer", `Using RBR for positioning`, rbr);
1992
- // 2. Calculate text position based on r_t-position + offsets
1993
- const position = config.textPosition || "o"; // Default to origin if not specified
1994
- const targetPos = this.calculateTargetPosition(position, rbr);
1995
- // 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";
1996
1422
  const offsetX = parseOffset(config.textOffsetX || 0, rbr.height, rbr.width);
1997
1423
  const offsetY = parseOffset(config.textOffsetY || 0, rbr.height, rbr.width);
1998
- const baseX = targetPos.x + offsetX;
1999
- 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();
2000
1471
  this._logger.debug("TextRenderer", `Text position calculated`, {
2001
1472
  position,
2002
- targetPos,
1473
+ rbr,
1474
+ ebr,
1475
+ rbrAlignPoint,
1476
+ ebrAlignPoint,
2003
1477
  offsets: { x: offsetX, y: offsetY },
2004
- base: { x: baseX, y: baseY },
1478
+ translation: { dx, dy },
2005
1479
  });
2006
- // 3. Calculate positions for each character
2007
- const positions = this.calculateAdditionalTextPositions(nodes, baseX, baseY);
2008
- // 4. Calculate bounding rectangle for the text (needed for spreading and prolog)
2009
- const textBounds = this.calculateTextBounds(nodes, positions);
2010
- // 5. Apply spreading if r_t-solid=1
2011
- // Check config.textSolid to determine if spreading should be applied
2012
- if (config.textSolid) {
2013
- if (textBounds) {
2014
- this._logger.debug("TextRenderer", `Applying spreading for solid additional text`, textBounds);
2015
- const spreading = this._spreadingEngine.calculateSpreading(textBounds, [
2016
- `text-group-${versionTag}`,
2017
- ]);
2018
- if (spreading.hasShifts) {
2019
- // Combine horizontal and vertical shifts
2020
- const combinedShifts = new Map();
2021
- const allIds = new Set([
2022
- ...spreading.horizontalShifts.keys(),
2023
- ...spreading.verticalShifts.keys(),
2024
- ]);
2025
- for (const id of allIds) {
2026
- combinedShifts.set(id, {
2027
- x: spreading.horizontalShifts.get(id) || 0,
2028
- y: spreading.verticalShifts.get(id) || 0,
2029
- });
2030
- }
2031
- // Animate spreading
2032
- await this._animationEngine.animateSpreading(combinedShifts, this._settings.spreadTime, rootSvg);
2033
- // Update cached bounds for shifted elements
2034
- for (const id of allIds) {
2035
- const element = rootSvg.querySelector(`#${id}`);
2036
- if (element) {
2037
- this._boundsCache.updateFromElement(element);
2038
- }
2039
- }
2040
- }
2041
- }
2042
- }
2043
- else {
2044
- this._logger.debug("TextRenderer", `Skipping spreading - additional text is not solid (r_t-solid not set)`);
2045
- }
2046
- // 6. Check if prolog panning is needed (element visibility check)
2047
- // This must happen AFTER positioning and spreading, 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
2048
1490
  if (panZoomInstance &&
2049
1491
  viewportWidth &&
2050
1492
  viewportHeight &&
2051
- this._settings.prologDuration > 0 &&
2052
- textBounds) {
1493
+ this._settings.prologDuration > 0) {
2053
1494
  const isVisible = this._animationEngine.isElementVisible(textBounds, panZoomInstance, viewportWidth, viewportHeight);
2054
1495
  if (!isVisible) {
2055
1496
  this._logger.debug("TextRenderer", `Additional text for ${versionTag} would be outside viewport, executing prolog`);
2056
1497
  await this._animationEngine.animateProlog(panZoomInstance, textBounds, viewportWidth, viewportHeight, this._settings.prologDuration);
2057
1498
  }
2058
1499
  }
2059
- // 7. Get animation function if specified
1500
+ // 8. Get animation function if specified
2060
1501
  const animationFn = this._settings.charAnimationId
2061
1502
  ? this._animationEngine
2062
1503
  .getFactory()
2063
1504
  .resolveAnimation(`#${this._settings.charAnimationId}`, this._settings.animations, "char")
2064
1505
  : undefined;
2065
- // 8. Render each character with features
2066
- for (let i = 0; i < nodes.length; i++) {
2067
- const node = nodes[i];
2068
- const pos = positions[i];
2069
- // Skip rendering for line breaks and spaces
2070
- if (isLineBreak(node) || isSpace(node)) {
2071
- 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);
2072
1516
  }
2073
- await this.renderCharacter(node, pos, rootSvg, versionTag, animationFn, config);
2074
1517
  }
2075
1518
  this._logger.timeEnd(`renderAdditionalText-${versionTag}`);
2076
1519
  }
2077
1520
  /**
2078
1521
  * Calculate positions for additional text characters.
2079
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.
2080
1525
  */
2081
- calculateAdditionalTextPositions(nodes, baseX, baseY) {
1526
+ calculateAdditionalTextPositions(nodes, baseX, baseY, config) {
2082
1527
  const positions = [];
2083
1528
  let currentX = baseX;
2084
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;
2085
1534
  for (let i = 0; i < nodes.length; i++) {
2086
1535
  const node = nodes[i];
2087
1536
  if (isLineBreak(node)) {
2088
- // Line breaks in additional text just get stored but don't render
2089
- positions.push({
2090
- x: currentX,
2091
- y: currentY,
2092
- nodeId: node.id,
2093
- lineNumber: 0,
2094
- });
1537
+ positions.push({ x: currentX, y: currentY, nodeId: node.id, lineNumber: 0 });
2095
1538
  }
2096
1539
  else if (isSpace(node)) {
2097
- // Space: advance by space width
2098
- const spaceWidth = this._settings.fontSize * 0.33;
2099
- positions.push({
2100
- x: currentX,
2101
- y: currentY,
2102
- nodeId: node.id,
2103
- lineNumber: 0,
2104
- });
1540
+ const spaceWidth = fontSize * 0.33;
1541
+ positions.push({ x: currentX, y: currentY, nodeId: node.id, lineNumber: 0 });
2105
1542
  currentX += spaceWidth + this._settings.charSpacing;
2106
1543
  }
2107
1544
  else {
2108
- // Regular character
2109
- positions.push({
2110
- x: currentX,
2111
- y: currentY,
2112
- nodeId: node.id,
2113
- lineNumber: 0,
2114
- });
2115
- // Calculate character width for next position
2116
- // Use measurement root for consistent font metrics with actual rendering
2117
- 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);
2118
1547
  currentX += charWidth + this._settings.charSpacing;
2119
1548
  }
2120
1549
  }
2121
1550
  return positions;
2122
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
+ }
2123
1600
  /**
2124
1601
  * Calculate RBRs from reference nodes.
2125
1602
  * Nodes on different lines create separate RBRs.
@@ -2162,69 +1639,6 @@ class TextRenderer {
2162
1639
  }
2163
1640
  return rbrs;
2164
1641
  }
2165
- /**
2166
- * Calculate target position on the RBR based on position type.
2167
- */
2168
- calculateTargetPosition(position, rbr) {
2169
- const centerX = rbr.x + rbr.width / 2;
2170
- const centerY = rbr.y + rbr.height / 2;
2171
- switch (position) {
2172
- case "n": // North (top center)
2173
- return { x: centerX, y: rbr.y };
2174
- case "ne": // Northeast (top right)
2175
- return { x: rbr.right, y: rbr.y };
2176
- case "e": // East (middle right)
2177
- return { x: rbr.right, y: centerY };
2178
- case "se": // Southeast (bottom right)
2179
- return { x: rbr.right, y: rbr.bottom };
2180
- case "s": // South (bottom center)
2181
- return { x: centerX, y: rbr.bottom };
2182
- case "sw": // Southwest (bottom left)
2183
- return { x: rbr.x, y: rbr.bottom };
2184
- case "w": // West (middle left)
2185
- return { x: rbr.x, y: centerY };
2186
- case "nw": // Northwest (top left)
2187
- return { x: rbr.x, y: rbr.y };
2188
- case "o": // Origin (center)
2189
- default:
2190
- return { x: centerX, y: centerY };
2191
- }
2192
- }
2193
- /**
2194
- * Calculate bounding rectangle for a set of positioned characters.
2195
- */
2196
- calculateTextBounds(nodes, positions) {
2197
- if (nodes.length === 0)
2198
- return null;
2199
- // We need to estimate bounds before rendering
2200
- // Use approximate character dimensions based on settings
2201
- const charWidth = this._settings.fontSize * 0.6; // Approximate
2202
- const charHeight = this._settings.fontSize;
2203
- let minX = Infinity;
2204
- let minY = Infinity;
2205
- let maxX = -Infinity;
2206
- let maxY = -Infinity;
2207
- for (let i = 0; i < nodes.length; i++) {
2208
- const pos = positions[i];
2209
- const node = nodes[i];
2210
- if (isLineBreak(node) || isSpace(node))
2211
- continue;
2212
- minX = Math.min(minX, pos.x);
2213
- minY = Math.min(minY, pos.y - charHeight);
2214
- maxX = Math.max(maxX, pos.x + charWidth);
2215
- maxY = Math.max(maxY, pos.y);
2216
- }
2217
- if (!isFinite(minX))
2218
- return null;
2219
- return {
2220
- x: minX,
2221
- y: minY,
2222
- width: maxX - minX,
2223
- height: maxY - minY,
2224
- right: maxX,
2225
- bottom: maxY,
2226
- };
2227
- }
2228
1642
  /**
2229
1643
  * Update settings.
2230
1644
  */
@@ -2239,12 +1653,11 @@ class TextRenderer {
2239
1653
  * Hints are visual counterparts of editing operations.
2240
1654
  */
2241
1655
  class HintRenderer {
2242
- constructor(settings, logger, animationEngine, boundsCache, spreadingEngine) {
1656
+ constructor(settings, logger, animationEngine, boundsCache) {
2243
1657
  this._settings = settings;
2244
1658
  this._logger = logger;
2245
1659
  this._animationEngine = animationEngine;
2246
1660
  this._boundsCache = boundsCache;
2247
- this._spreadingEngine = spreadingEngine;
2248
1661
  }
2249
1662
  /**
2250
1663
  * Render a hint for an operation.
@@ -2269,8 +1682,8 @@ class HintRenderer {
2269
1682
  this._logger.info(`Rendering hint: ${hintId} for operation ${operationId}`);
2270
1683
  try {
2271
1684
  // 1. Apply r_h-* overrides to hint properties
2272
- // Overrides can target this specific hint or all hints ("*")
2273
- const effectiveHint = this.applyHintOverrides(hint, hintId, hintOverrides, allBaseNodes);
1685
+ // Overrides can target this specific hint (by key or ordinal) or all hints ("*")
1686
+ const effectiveHint = this.applyHintOverrides(hint, hintId, hintOrdinal, hintOverrides, allBaseNodes);
2274
1687
  // 2. Resolve placeholders in SVG
2275
1688
  const resolvedSvg = resolvePlaceholders(effectiveHint.svg, variables);
2276
1689
  this._logger.debug("HintRenderer", `Placeholders resolved`, {
@@ -2302,6 +1715,12 @@ class HintRenderer {
2302
1715
  }
2303
1716
  // 5. Check if this is a placeholder hint
2304
1717
  const hasPlaceholder = hintGroup.querySelector("#placeholder") !== null;
1718
+ // Default-mode placeholder: a placeholder element WITHOUT class="fit".
1719
+ // Per documentation, in default mode the text keeps its natural size and the
1720
+ // container (if any) scales to fit it — meaning no RBR-based scaling is applied.
1721
+ // Fit-mode placeholders still scale the hint to the RBR normally.
1722
+ const isDefaultPlaceholder = hasPlaceholder &&
1723
+ !hintGroup.querySelector("#placeholder")?.classList.contains("fit");
2305
1724
  // 6. Render hint for each RBR (unless it's a placeholder hint and already rendered once)
2306
1725
  let placeholderRendered = false;
2307
1726
  for (let i = 0; i < rbrs.length; i++) {
@@ -2322,6 +1741,13 @@ class HintRenderer {
2322
1741
  rootSvg.appendChild(currentHintGroup);
2323
1742
  // 8. Get the scale factors needed to fit RBR
2324
1743
  const scalingInfo = this.calculateScalingInfo(currentHintGroup, effectiveHint, rbr);
1744
+ // For default-mode placeholder hints the text drives the size, not the RBR.
1745
+ // Override any RBR-derived scale so the hint keeps its natural rendered size.
1746
+ // The EBR (measured from the natural-size hint) is then used to center it.
1747
+ if (isDefaultPlaceholder) {
1748
+ scalingInfo.scaleX = 1;
1749
+ scalingInfo.scaleY = 1;
1750
+ }
2325
1751
  // 9. Apply scale and rotation transforms to prepare the hint
2326
1752
  await this.applyHintSizeTransforms(currentHintGroup, effectiveHint, scalingInfo);
2327
1753
  // 10. Get EBR (Element Bounding Rectangle) AFTER transformations
@@ -2329,11 +1755,7 @@ class HintRenderer {
2329
1755
  const ebrBounds = this.getEBRBounds(currentHintGroup);
2330
1756
  // 11. Apply positioning transform - align EBR with RBR based on position
2331
1757
  await this.applyHintPositionTransform(currentHintGroup, effectiveHint, rbr, ebrBounds, scalingInfo);
2332
- // 10. Handle spreading if solid
2333
- if (effectiveHint.solid) {
2334
- await this.handleSpreading(currentHintGroup, rootSvg, versionTag);
2335
- }
2336
- // 11. Check if prolog panning is needed (element visibility check)
1758
+ // 10. Check if prolog panning is needed (element visibility check)
2337
1759
  // This must happen AFTER positioning and spreading, but BEFORE making visible
2338
1760
  if (panZoomInstance && viewportWidth && viewportHeight && this._settings.prologDuration > 0) {
2339
1761
  // IMPORTANT: Use getTransformedBBox() instead of getSafeBBox() here!
@@ -2473,12 +1895,6 @@ class HintRenderer {
2473
1895
  scaledSize: { width, height },
2474
1896
  });
2475
1897
  }
2476
- // Apply hint margin
2477
- if (this._settings.hintMargin > 0) {
2478
- width += this._settings.hintMargin * 2;
2479
- height += this._settings.hintMargin * 2;
2480
- this._logger.debug("HintRenderer", `Applied hint margin: ${this._settings.hintMargin}px`);
2481
- }
2482
1898
  // Calculate scale factors based on current hint size
2483
1899
  const currentBBox = getSafeBBox(hintGroup);
2484
1900
  const scaleX = currentBBox.width > 0 ? width / currentBBox.width : 1;
@@ -2674,74 +2090,32 @@ class HintRenderer {
2674
2090
  return { x: centerX, y: centerY };
2675
2091
  }
2676
2092
  }
2677
- /**
2678
- * Handle spreading for solid hints.
2679
- * @param versionTag - Version tag for tracking spreading history
2680
- */
2681
- async handleSpreading(hintGroup, rootSvg, versionTag) {
2682
- const hintBounds = getSafeBBox(hintGroup);
2683
- const hintId = hintGroup.getAttribute("id");
2684
- const spreading = this._spreadingEngine.calculateSpreading(hintBounds, [
2685
- hintId,
2686
- ]);
2687
- if (spreading.hasShifts) {
2688
- this._logger.debug("HintRenderer", `Spreading required for hint ${hintId}`, {
2689
- horizontalShifts: spreading.horizontalShifts.size,
2690
- verticalShifts: spreading.verticalShifts.size,
2691
- });
2692
- // Combine horizontal and vertical shifts
2693
- const combinedShifts = new Map();
2694
- const allIds = new Set([
2695
- ...spreading.horizontalShifts.keys(),
2696
- ...spreading.verticalShifts.keys(),
2697
- ]);
2698
- for (const id of allIds) {
2699
- combinedShifts.set(id, {
2700
- x: spreading.horizontalShifts.get(id) || 0,
2701
- y: spreading.verticalShifts.get(id) || 0,
2702
- });
2703
- }
2704
- // Animate spreading
2705
- await this._animationEngine.animateSpreading(combinedShifts, this._settings.spreadTime, rootSvg);
2706
- // Update cached bounds for shifted elements
2707
- for (const id of allIds) {
2708
- const element = rootSvg.querySelector(`#${id}`);
2709
- if (element) {
2710
- this._boundsCache.updateFromElement(element);
2711
- }
2712
- }
2713
- // Track spreading metadata for backward navigation
2714
- // Extract version index from versionTag (e.g., "v2" → 2)
2715
- const versionMatch = versionTag.match(/v(\d+)/);
2716
- if (versionMatch) {
2717
- const versionIndex = parseInt(versionMatch[1]);
2718
- this._spreadingEngine.trackSpreading(versionIndex, spreading.horizontalShifts, spreading.verticalShifts);
2719
- }
2720
- }
2721
- }
2722
2093
  /**
2723
2094
  * Apply r_h-* overrides to hint properties.
2724
- * Overrides can target specific hints or all hints ("*").
2095
+ * Priority (lowest to highest): wildcard ("*") < key-based < ordinal-based.
2725
2096
  *
2726
2097
  * @param hint - Original hint design
2727
- * @param hintId - ID of the hint being rendered
2728
- * @param hintOverrides - Map of overrides from r_h-* features
2098
+ * @param hintId - ID (key) of the hint being rendered
2099
+ * @param hintOrdinal - 1-based ordinal position of this hint within the operation
2100
+ * @param hintOverrides - Map of overrides from r_h-* features (keyed by ID string or ordinal number)
2729
2101
  * @param allBaseNodes - All base nodes for displaced span resolution
2730
2102
  * @returns Modified hint with overrides applied
2731
2103
  */
2732
- applyHintOverrides(hint, hintId, hintOverrides, allBaseNodes) {
2104
+ applyHintOverrides(hint, hintId, hintOrdinal, hintOverrides, allBaseNodes) {
2733
2105
  if (!hintOverrides || hintOverrides.size === 0) {
2734
2106
  return hint;
2735
2107
  }
2736
2108
  // Clone hint to avoid modifying original
2737
2109
  const effectiveHint = { ...hint };
2738
- // Check for overrides targeting this specific hint or all hints
2739
- const specificOverride = hintOverrides.get(hintId);
2110
+ // Look up overrides by wildcard, key, and ordinal; merge in priority order
2740
2111
  const wildcardOverride = hintOverrides.get("*");
2741
- // Apply wildcard overrides first, then specific overrides (so specific wins)
2112
+ const specificOverride = hintOverrides.get(hintId);
2113
+ const ordinalOverride = hintOverrides.get(hintOrdinal);
2114
+ // Ordinal (most specific) wins over key-specific, which wins over wildcard
2742
2115
  const override = {
2743
2116
  ...wildcardOverride,
2744
2117
  ...specificOverride,
2118
+ ...ordinalOverride,
2745
2119
  };
2746
2120
  // Apply each override property
2747
2121
  if (override.position !== undefined) {
@@ -2768,10 +2142,6 @@ class HintRenderer {
2768
2142
  effectiveHint.rotation = parseFloat(override.rotation);
2769
2143
  this._logger.debug("HintRenderer", `Override rotation for ${hintId}: ${override.rotation}`);
2770
2144
  }
2771
- if (override.solid !== undefined) {
2772
- effectiveHint.solid = override.solid;
2773
- this._logger.debug("HintRenderer", `Override solid for ${hintId}: ${override.solid}`);
2774
- }
2775
2145
  if (override.displacedSpan !== undefined) {
2776
2146
  // Convert to displacedRefSpan format (e.g., "64x3")
2777
2147
  const { nodeId, count } = override.displacedSpan;
@@ -2788,6 +2158,262 @@ class HintRenderer {
2788
2158
  }
2789
2159
  }
2790
2160
 
2161
+ /**
2162
+ * Feature resolver converts features into rendition configuration.
2163
+ * This handles all the r_* rendition features and applies them to create
2164
+ * a complete configuration for rendering.
2165
+ */
2166
+ class FeatureResolver {
2167
+ constructor(logger) {
2168
+ this._logger = logger;
2169
+ }
2170
+ /**
2171
+ * Resolve rendition features into a complete configuration.
2172
+ * Features override the base settings.
2173
+ *
2174
+ * @param baseSettings - The base settings from component
2175
+ * @param features - Array of features to apply
2176
+ * @param nodeFeatureContext - Optional context for extracting node features for placeholder resolution
2177
+ * @returns Resolved rendition configuration
2178
+ */
2179
+ resolve(baseSettings, features, nodeFeatureContext) {
2180
+ const config = {
2181
+ fontSize: baseSettings.fontSize,
2182
+ fontFamily: baseSettings.fontFamily,
2183
+ foreColor: baseSettings.foreColor,
2184
+ backColor: baseSettings.backColor,
2185
+ italic: baseSettings.italic,
2186
+ bold: baseSettings.bold,
2187
+ underline: baseSettings.underline,
2188
+ overline: baseSettings.overline,
2189
+ strike: baseSettings.strike,
2190
+ textOffsetX: 0,
2191
+ textOffsetY: 0,
2192
+ };
2193
+ if (!features || features.length === 0) {
2194
+ return config;
2195
+ }
2196
+ // Initialize hintVars map - will contain ALL feature values for placeholder
2197
+ // replacement
2198
+ config.hintVars = new Map();
2199
+ // Initialize hintOverrides map for r_h-* features
2200
+ config.hintOverrides = new Map();
2201
+ // Extract ALL features from reference nodes for placeholder resolution.
2202
+ // This includes features like "note", "group", etc. that don't have r_ prefix.
2203
+ // Features are now stored per-node keyed by version tag.
2204
+ if (nodeFeatureContext) {
2205
+ const { refNodes, outputTag } = nodeFeatureContext;
2206
+ for (const node of refNodes) {
2207
+ const nodeFeaturesArray = node.features?.[outputTag];
2208
+ if (!nodeFeaturesArray)
2209
+ continue;
2210
+ for (const nodeFeature of nodeFeaturesArray) {
2211
+ if (nodeFeature.name.startsWith("$"))
2212
+ continue;
2213
+ if (!config.hintVars.has(nodeFeature.name)) {
2214
+ config.hintVars.set(nodeFeature.name, nodeFeature.value);
2215
+ this._logger.debug("FeatureResolver", `Added node feature to hintVars from node ${node.id} @ ${outputTag}: ${nodeFeature.name}=${nodeFeature.value}`);
2216
+ }
2217
+ }
2218
+ }
2219
+ }
2220
+ // Process each rendition feature from operation
2221
+ for (const feature of features) {
2222
+ if (!feature.name.startsWith("r_"))
2223
+ continue;
2224
+ const value = feature.value;
2225
+ // Add ALL r_* features to hintVars for placeholder replacement
2226
+ // This allows {{r_fore-color}}, {{r_font-size}}, etc. in hint SVG templates
2227
+ // These override any node features with the same name
2228
+ config.hintVars.set(feature.name, value);
2229
+ switch (feature.name) {
2230
+ case "r_font-size":
2231
+ config.fontSize = parseFloat(value);
2232
+ break;
2233
+ case "r_font-family":
2234
+ config.fontFamily = value;
2235
+ break;
2236
+ case "r_fore-color":
2237
+ config.foreColor = value;
2238
+ break;
2239
+ case "r_back-color":
2240
+ config.backColor = value;
2241
+ break;
2242
+ case "r_italic":
2243
+ config.italic = value === "true" || value === "1";
2244
+ break;
2245
+ case "r_bold":
2246
+ config.bold = value === "true" || value === "1";
2247
+ break;
2248
+ case "r_underline":
2249
+ config.underline = parseFloat(value);
2250
+ break;
2251
+ case "r_overline":
2252
+ config.overline = parseFloat(value);
2253
+ break;
2254
+ case "r_strike":
2255
+ config.strike = parseFloat(value);
2256
+ break;
2257
+ case "r_text-line-style":
2258
+ config.textLineStyle = value;
2259
+ break;
2260
+ case "r_text-line-color":
2261
+ config.textLineColor = value;
2262
+ break;
2263
+ case "r_rotate":
2264
+ config.rotate = parseFloat(value);
2265
+ break;
2266
+ // r_t-* features for added text
2267
+ case "r_t-position":
2268
+ config.textPosition = value;
2269
+ break;
2270
+ case "r_t-offset-x":
2271
+ // Store the value as-is (number or string). It will be parsed later
2272
+ // in renderAdditionalText when RBR bounds are available.
2273
+ config.textOffsetX = typeof value === "number" ? value : value;
2274
+ break;
2275
+ case "r_t-offset-y":
2276
+ // Store the value as-is (number or string). It will be parsed later
2277
+ // in renderAdditionalText when RBR bounds are available.
2278
+ config.textOffsetY = typeof value === "number" ? value : value;
2279
+ break;
2280
+ case "r_t-displaced-span":
2281
+ config.textDisplacedSpan = parseDisplacedSpan(value);
2282
+ if (!config.textDisplacedSpan) {
2283
+ this._logger.warn(`Invalid r_t-displaced-span value: ${value}`);
2284
+ }
2285
+ break;
2286
+ case "r_t-value":
2287
+ config.textValue = value;
2288
+ break;
2289
+ case "r_hints":
2290
+ config.hints = value.trim().split(/\s+/);
2291
+ break;
2292
+ case "r_hint-vars":
2293
+ // Parse custom variables and merge with existing hintVars
2294
+ const customVars = parseHintVars(value);
2295
+ customVars.forEach((val, key) => {
2296
+ config.hintVars.set(key, val);
2297
+ });
2298
+ break;
2299
+ // r_h-* features for hint property overrides
2300
+ default:
2301
+ if (feature.name.startsWith("r_h-")) {
2302
+ this.parseHintOverride(feature.name, value, config);
2303
+ }
2304
+ break;
2305
+ }
2306
+ }
2307
+ return config;
2308
+ }
2309
+ /**
2310
+ * Extract character offsets from init features.
2311
+ * This is used for base text layout adjustments.
2312
+ *
2313
+ * @param features - Features from the first annotate operation (init features)
2314
+ * @returns Map of node IDs to their offsets (values may be numbers or "Ntw"/"Nth" strings)
2315
+ */
2316
+ extractCharOffsets(features) {
2317
+ if (!features)
2318
+ return new Map();
2319
+ const charOffsetsValue = getFeatureValue(features, "r_char-offsets");
2320
+ if (!charOffsetsValue)
2321
+ return new Map();
2322
+ return parseCharOffsets(charOffsetsValue);
2323
+ }
2324
+ /**
2325
+ * Check if features contain a specific rendition feature.
2326
+ */
2327
+ hasRenditionFeature(features, featureName) {
2328
+ if (!features)
2329
+ return false;
2330
+ return features.some((f) => f.name === featureName);
2331
+ }
2332
+ /**
2333
+ * Get the value of a rendition feature.
2334
+ */
2335
+ getRenditionFeatureValue(features, featureName) {
2336
+ return getFeatureValue(features, featureName);
2337
+ }
2338
+ /**
2339
+ * Parse r_h-* hint override features.
2340
+ * Format: "value" applies to all hints, or "@target1 target2:value" applies to specific hints.
2341
+ * Targets can be hint ID strings or 1-based ordinal integers (e.g., "@1 beta:e" targets
2342
+ * the first hint by position and all hints with key "beta"). Numbers and keys can be mixed.
2343
+ *
2344
+ * @param featureName - The feature name (e.g., "r_h-position")
2345
+ * @param value - The feature value
2346
+ * @param config - The rendition config to update
2347
+ */
2348
+ parseHintOverride(featureName, value, config) {
2349
+ // Extract property name from feature name (r_h-position -> position)
2350
+ const propertyName = featureName.substring(4); // Remove "r_h-"
2351
+ // Parse value to check if it targets specific hints
2352
+ // null = all hints; otherwise an array of string keys and/or numeric ordinals
2353
+ let targetHints = null;
2354
+ let actualValue = value;
2355
+ if (value.startsWith("@")) {
2356
+ // Format: "@target1 target2:value" where targets are hint IDs or 1-based ordinals
2357
+ const colonIndex = value.indexOf(":");
2358
+ if (colonIndex > 0) {
2359
+ const hintIdsStr = value.substring(1, colonIndex); // Remove "@" and get up to ":"
2360
+ targetHints = hintIdsStr.trim().split(/\s+/).map((token) => {
2361
+ const n = parseInt(token, 10);
2362
+ // Use numeric key for pure integer tokens (ordinal-based targeting)
2363
+ return !isNaN(n) && String(n) === token ? n : token;
2364
+ });
2365
+ actualValue = value.substring(colonIndex + 1);
2366
+ }
2367
+ else {
2368
+ this._logger.warn(`Invalid r_h-* format (missing colon): ${featureName}=${value}`);
2369
+ return;
2370
+ }
2371
+ }
2372
+ // If no hints defined yet in config, we'll still store the override.
2373
+ // It will be applied when hints are processed.
2374
+ const hints = targetHints ?? (config.hints ?? ["*"]);
2375
+ for (const hintKey of hints) {
2376
+ // Get or create override object for this hint key / ordinal
2377
+ let override = config.hintOverrides.get(hintKey);
2378
+ if (!override) {
2379
+ override = {};
2380
+ config.hintOverrides.set(hintKey, override);
2381
+ }
2382
+ // Set the property value
2383
+ switch (propertyName) {
2384
+ case "position":
2385
+ override.position = actualValue;
2386
+ break;
2387
+ case "offset-x":
2388
+ override.offsetX = actualValue;
2389
+ break;
2390
+ case "offset-y":
2391
+ override.offsetY = actualValue;
2392
+ break;
2393
+ case "scale-x":
2394
+ override.scaleX = actualValue;
2395
+ break;
2396
+ case "scale-y":
2397
+ override.scaleY = actualValue;
2398
+ break;
2399
+ case "rotation":
2400
+ override.rotation = actualValue;
2401
+ break;
2402
+ case "displaced-span":
2403
+ override.displacedSpan = parseDisplacedSpan(actualValue);
2404
+ if (!override.displacedSpan) {
2405
+ this._logger.warn(`Invalid r_h-displaced-span value: ${actualValue}`);
2406
+ }
2407
+ break;
2408
+ default:
2409
+ this._logger.warn(`Unknown hint override property: ${propertyName}`);
2410
+ break;
2411
+ }
2412
+ this._logger.debug("FeatureResolver", `Added hint override for ${hintKey}: ${propertyName}=${actualValue}`);
2413
+ }
2414
+ }
2415
+ }
2416
+
2791
2417
  function getDefaultExportFromCjs (x) {
2792
2418
  return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
2793
2419
  }
@@ -26291,7 +25917,7 @@ class GveSnapshotRendition extends HTMLElement {
26291
25917
  * of the web component is loaded.
26292
25918
  */
26293
25919
  static get version() {
26294
- return "1.0.2";
25920
+ return "2.0.2";
26295
25921
  }
26296
25922
  constructor() {
26297
25923
  super();
@@ -26308,9 +25934,7 @@ class GveSnapshotRendition extends HTMLElement {
26308
25934
  this._autoForwardEnabled = this._settings.autoForwardOnGroup;
26309
25935
  // Create logger
26310
25936
  this._logger = new Logger("GVE-Rendition", this._settings.debug);
26311
- // Create bounds cache and spreading engine
26312
25937
  this._boundsCache = new BoundsCache(this._logger);
26313
- this._spreadingEngine = new SpreadingEngine(this._boundsCache, this._logger);
26314
25938
  // Create feature resolver
26315
25939
  this._featureResolver = new FeatureResolver(this._logger);
26316
25940
  // Create shadow DOM
@@ -26422,8 +26046,8 @@ class GveSnapshotRendition extends HTMLElement {
26422
26046
  const gsap = window.gsap;
26423
26047
  if (gsap) {
26424
26048
  this._animationEngine = new AnimationEngine(gsap, this._logger);
26425
- this._textRenderer = new TextRenderer(this._settings, this._logger, this._animationEngine, this._boundsCache, this._spreadingEngine);
26426
- this._hintRenderer = new HintRenderer(this._settings, this._logger, this._animationEngine, this._boundsCache, this._spreadingEngine);
26049
+ this._textRenderer = new TextRenderer(this._settings, this._logger, this._animationEngine, this._boundsCache);
26050
+ this._hintRenderer = new HintRenderer(this._settings, this._logger, this._animationEngine, this._boundsCache);
26427
26051
  this._logger.info("GSAP initialized");
26428
26052
  this.renderContent();
26429
26053
  }
@@ -27176,39 +26800,7 @@ class GveSnapshotRendition extends HTMLElement {
27176
26800
  return;
27177
26801
  }
27178
26802
  this._logger.info(`Removing ${elements.length} elements from v${versionIndex}`);
27179
- // Step 1: Reverse spreading animation if spreadTime > 0
27180
- // This must happen BEFORE removing elements and BEFORE fadeout
27181
- if (this._settings.spreadTime > 0 &&
27182
- this._spreadingEngine &&
27183
- this._animationEngine) {
27184
- const spreadingMetadata = this._spreadingEngine.getSpreadingForVersion(versionIndex);
27185
- if (spreadingMetadata.size > 0) {
27186
- this._logger.debug("Navigation", `Reversing spreading for ${spreadingMetadata.size} elements from v${versionIndex}`);
27187
- // Build combined shifts (negative of original shifts to reverse)
27188
- const reverseShifts = new Map();
27189
- for (const [elementId, metadata] of spreadingMetadata.entries()) {
27190
- reverseShifts.set(elementId, {
27191
- x: -metadata.horizontalShift, // Negative to reverse
27192
- y: -metadata.verticalShift, // Negative to reverse
27193
- });
27194
- }
27195
- // Animate spreading reversal
27196
- await this._animationEngine.animateSpreading(reverseShifts, this._settings.spreadTime, container // Use container for animation context
27197
- );
27198
- // Update cached bounds for shifted elements
27199
- if (this._boundsCache) {
27200
- for (const elementId of reverseShifts.keys()) {
27201
- const element = container.querySelector(`#${elementId}`);
27202
- if (element) {
27203
- this._boundsCache.updateFromElement(element);
27204
- }
27205
- }
27206
- }
27207
- // Clear spreading history for this version
27208
- this._spreadingEngine.clearSpreadingForVersion(versionIndex);
27209
- }
27210
- }
27211
- // Step 2: If backwardFadeOutTime > 0, animate fadeout
26803
+ // Step 1: If backwardFadeOutTime > 0, animate fadeout
27212
26804
  if (this._settings.backwardFadeOutTime > 0) {
27213
26805
  const fadeOutPromises = [];
27214
26806
  elements.forEach((el) => {
@@ -27220,7 +26812,7 @@ class GveSnapshotRendition extends HTMLElement {
27220
26812
  });
27221
26813
  await Promise.all(fadeOutPromises);
27222
26814
  }
27223
- // Step 3: Remove elements from DOM
26815
+ // Step 2: Remove elements from DOM
27224
26816
  elements.forEach((el) => el.remove());
27225
26817
  }
27226
26818
  /**
@@ -41212,7 +40804,7 @@ function requireD () {
41212
40804
  + 'pragma private protected public pure ref return scope shared static struct '
41213
40805
  + 'super switch synchronized template this throw try typedef typeid typeof union '
41214
40806
  + 'unittest version void volatile while with __FILE__ __LINE__ __gshared|10 '
41215
- + '__thread __traits __DATE__ __EOF__ __TIME__ __TIMESTAMP__ __VENDOR__ 1.0.2',
40807
+ + '__thread __traits __DATE__ __EOF__ __TIME__ __TIMESTAMP__ __VENDOR__ 2.0.2',
41216
40808
  built_in:
41217
40809
  'bool cdouble cent cfloat char creal dchar delegate double dstring float function '
41218
40810
  + 'idouble ifloat ireal long real short string ubyte ucent uint ulong ushort wchar '
@@ -91176,10 +90768,6 @@ class GveHintDesigner extends HTMLElement {
91176
90768
  // Rotation
91177
90769
  this._rotationInput = this.createInput("number", "0");
91178
90770
  form.appendChild(this.createFormRow("Rotation:", this._rotationInput));
91179
- // Solid
91180
- this._solidCheckbox = document.createElement("input");
91181
- this._solidCheckbox.type = "checkbox";
91182
- form.appendChild(this.createFormRow("Solid:", this._solidCheckbox));
91183
90771
  // Displaced Ref Span
91184
90772
  this._displacedRefSpanInput = this.createInput("text", "");
91185
90773
  form.appendChild(this.createFormRow("Displaced Ref Span:", this._displacedRefSpanInput));
@@ -91665,8 +91253,6 @@ class GveHintDesigner extends HTMLElement {
91665
91253
  this._scaleYInput.value = hint.scaleY?.toString() || "1";
91666
91254
  if (this._rotationInput)
91667
91255
  this._rotationInput.value = hint.rotation?.toString() || "0";
91668
- if (this._solidCheckbox)
91669
- this._solidCheckbox.checked = hint.solid || false;
91670
91256
  if (this._displacedRefSpanInput)
91671
91257
  this._displacedRefSpanInput.value = hint.displacedRefSpan || "";
91672
91258
  if (this._svgTextarea) {
@@ -91732,8 +91318,6 @@ class GveHintDesigner extends HTMLElement {
91732
91318
  this._scaleYInput.value = "1";
91733
91319
  if (this._rotationInput)
91734
91320
  this._rotationInput.value = "0";
91735
- if (this._solidCheckbox)
91736
- this._solidCheckbox.checked = false;
91737
91321
  if (this._displacedRefSpanInput)
91738
91322
  this._displacedRefSpanInput.value = "";
91739
91323
  if (this._svgTextarea)
@@ -91806,7 +91390,6 @@ class GveHintDesigner extends HTMLElement {
91806
91390
  scaleX: 1,
91807
91391
  scaleY: 1,
91808
91392
  rotation: 0,
91809
- solid: false,
91810
91393
  animation: "",
91811
91394
  };
91812
91395
  this._data.hints[hintId] = newHint;
@@ -91851,7 +91434,6 @@ class GveHintDesigner extends HTMLElement {
91851
91434
  scaleX: parseFloat(this._scaleXInput?.value || "1"),
91852
91435
  scaleY: parseFloat(this._scaleYInput?.value || "1"),
91853
91436
  rotation: parseFloat(this._rotationInput?.value || "0"),
91854
- solid: this._solidCheckbox?.checked || false,
91855
91437
  displacedRefSpan: this._displacedRefSpanInput?.value || undefined,
91856
91438
  };
91857
91439
  // Handle animation based on "embedded JS" option