kritzel-stencil 0.4.8 → 0.4.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.
Files changed (166) hide show
  1. package/dist/cjs/index.cjs.js +9 -1
  2. package/dist/cjs/kritzel-active-users_47.cjs.entry.js +1427 -1289
  3. package/dist/cjs/loader.cjs.js +1 -1
  4. package/dist/cjs/{schema.constants-gDeS7USU.js → schema.constants-C6PknABJ.js} +81 -6
  5. package/dist/cjs/stencil.cjs.js +1 -1
  6. package/dist/collection/classes/objects/image.class.js +9 -0
  7. package/dist/collection/classes/objects/path.class.js +9 -0
  8. package/dist/collection/classes/objects/shape.class.js +9 -0
  9. package/dist/collection/classes/providers/sync/broadcast-sync-provider.class.js +1 -0
  10. package/dist/collection/classes/providers/sync/hocuspocus-sync-provider.class.js +2 -0
  11. package/dist/collection/classes/providers/sync/in-memory-sync-provider.class.js +1 -0
  12. package/dist/collection/classes/providers/sync/indexeddb-sync-provider.class.js +1 -0
  13. package/dist/collection/classes/providers/sync/websocket-sync-provider.class.js +2 -0
  14. package/dist/collection/components/core/kritzel-editor/kritzel-editor.js +201 -67
  15. package/dist/collection/components/core/kritzel-engine/kritzel-engine.js +137 -170
  16. package/dist/collection/components/core/kritzel-watermark/kritzel-watermark.js +1 -1
  17. package/dist/collection/components/shared/kritzel-avatar/kritzel-avatar.js +3 -3
  18. package/dist/collection/components/shared/kritzel-button/kritzel-button.js +2 -2
  19. package/dist/collection/components/shared/kritzel-color/kritzel-color.js +2 -2
  20. package/dist/collection/components/shared/kritzel-color-palette/kritzel-color-palette.js +1 -1
  21. package/dist/collection/components/shared/kritzel-font/kritzel-font.js +1 -1
  22. package/dist/collection/components/shared/kritzel-font-size/kritzel-font-size.js +1 -1
  23. package/dist/collection/components/shared/kritzel-input/kritzel-input.js +1 -1
  24. package/dist/collection/components/shared/kritzel-master-detail/kritzel-master-detail.js +3 -3
  25. package/dist/collection/components/shared/kritzel-menu/kritzel-menu.js +1 -1
  26. package/dist/collection/components/shared/kritzel-menu-item/kritzel-menu-item.js +2 -2
  27. package/dist/collection/components/shared/kritzel-numeric-input/kritzel-numeric-input.js +1 -1
  28. package/dist/collection/components/shared/kritzel-opacity-slider/kritzel-opacity-slider.js +1 -1
  29. package/dist/collection/components/shared/kritzel-portal/kritzel-portal.js +1 -1
  30. package/dist/collection/components/shared/kritzel-slide-toggle/kritzel-slide-toggle.js +1 -1
  31. package/dist/collection/components/shared/kritzel-split-button/kritzel-split-button.js +1 -1
  32. package/dist/collection/components/shared/kritzel-stroke-size/kritzel-stroke-size.js +1 -1
  33. package/dist/collection/components/shared/kritzel-sync-indicator/kritzel-sync-indicator.css +5 -5
  34. package/dist/collection/components/shared/kritzel-sync-indicator/kritzel-sync-indicator.js +1 -1
  35. package/dist/collection/components/shared/kritzel-tooltip/kritzel-tooltip.js +2 -2
  36. package/dist/collection/components/ui/kritzel-back-to-content/kritzel-back-to-content.js +1 -1
  37. package/dist/collection/components/ui/kritzel-controls/kritzel-controls.js +7 -7
  38. package/dist/collection/components/ui/kritzel-current-user/kritzel-current-user.js +2 -2
  39. package/dist/collection/components/ui/kritzel-current-user-dialog/kritzel-current-user-dialog.js +2 -2
  40. package/dist/collection/components/ui/kritzel-export/kritzel-export.js +2 -2
  41. package/dist/collection/components/ui/kritzel-loading-overlay/kritzel-loading-overlay.css +6 -6
  42. package/dist/collection/components/ui/kritzel-loading-overlay/kritzel-loading-overlay.js +1 -1
  43. package/dist/collection/components/ui/kritzel-login-dialog/kritzel-login-dialog.js +1 -1
  44. package/dist/collection/components/ui/kritzel-more-menu/kritzel-more-menu.js +91 -5
  45. package/dist/collection/components/ui/kritzel-settings/kritzel-settings.js +2 -2
  46. package/dist/collection/components/ui/kritzel-share-dialog/kritzel-share-dialog.js +3 -3
  47. package/dist/collection/components/ui/kritzel-tool-config/kritzel-tool-config.js +1 -1
  48. package/dist/collection/components/ui/kritzel-utility-panel/kritzel-utility-panel.js +2 -2
  49. package/dist/collection/components/ui/kritzel-workspace-manager/kritzel-workspace-manager.js +1 -1
  50. package/dist/collection/components/ui/kritzel-zoom-panel/kritzel-zoom-panel.js +2 -2
  51. package/dist/collection/constants/version.js +1 -1
  52. package/dist/collection/helpers/object.helper.js +11 -0
  53. package/dist/collection/index.js +1 -0
  54. package/dist/collection/interfaces/object-export.interface.js +1 -0
  55. package/dist/collection/interfaces/sync-config.interface.js +21 -1
  56. package/dist/collection/locales/de-locale.js +2 -2
  57. package/dist/collection/locales/en-locale.js +2 -2
  58. package/dist/collection/locales/fr-locale.js +2 -2
  59. package/dist/collection/themes/dark-theme.js +7 -0
  60. package/dist/collection/themes/light-theme.js +7 -0
  61. package/dist/components/index.js +11 -4
  62. package/dist/components/kritzel-active-users.js +1 -1
  63. package/dist/components/kritzel-avatar.js +1 -1
  64. package/dist/components/kritzel-awareness-cursors.js +1 -1
  65. package/dist/components/kritzel-back-to-content.js +1 -1
  66. package/dist/components/kritzel-button.js +1 -1
  67. package/dist/components/kritzel-color-palette.js +1 -1
  68. package/dist/components/kritzel-color.js +1 -1
  69. package/dist/components/kritzel-controls.js +1 -1
  70. package/dist/components/kritzel-current-user-dialog.js +1 -1
  71. package/dist/components/kritzel-current-user.js +1 -1
  72. package/dist/components/kritzel-editor.js +200 -99
  73. package/dist/components/kritzel-engine.js +1 -1
  74. package/dist/components/kritzel-export.js +1 -1
  75. package/dist/components/kritzel-font-size.js +1 -1
  76. package/dist/components/kritzel-font.js +1 -1
  77. package/dist/components/kritzel-input.js +1 -1
  78. package/dist/components/kritzel-loading-overlay.js +1 -1
  79. package/dist/components/kritzel-login-dialog.js +1 -1
  80. package/dist/components/kritzel-master-detail.js +1 -1
  81. package/dist/components/kritzel-menu-item.js +1 -1
  82. package/dist/components/kritzel-menu.js +1 -1
  83. package/dist/components/kritzel-more-menu.js +1 -1
  84. package/dist/components/kritzel-numeric-input.js +1 -1
  85. package/dist/components/kritzel-opacity-slider.js +1 -1
  86. package/dist/components/kritzel-portal.js +1 -1
  87. package/dist/components/kritzel-settings.js +1 -1
  88. package/dist/components/kritzel-share-dialog.js +1 -1
  89. package/dist/components/kritzel-slide-toggle.js +1 -1
  90. package/dist/components/kritzel-split-button.js +1 -1
  91. package/dist/components/kritzel-stroke-size.js +1 -1
  92. package/dist/components/kritzel-sync-indicator.js +1 -1
  93. package/dist/components/kritzel-tool-config.js +1 -1
  94. package/dist/components/kritzel-tooltip.js +1 -1
  95. package/dist/components/kritzel-utility-panel.js +1 -1
  96. package/dist/components/kritzel-watermark.js +1 -1
  97. package/dist/components/kritzel-workspace-manager.js +1 -1
  98. package/dist/components/kritzel-zoom-panel.js +1 -1
  99. package/dist/components/{p-8xkoVMfO.js → p-2WiGyky4.js} +2 -2
  100. package/dist/components/{p-D8TOEhPY.js → p-30g2W_Gx.js} +3 -3
  101. package/dist/components/{p-DXcjIb_a.js → p-4SPnZZkH.js} +3 -3
  102. package/dist/components/{p-CURiiQfq.js → p-7I4Uzjrw.js} +14 -0
  103. package/dist/components/{p-9m1cAVwG.js → p-B-585egz.js} +3 -3
  104. package/dist/components/{p-COjsYPir.js → p-BP6Fo3rt.js} +2 -2
  105. package/dist/components/{p-BLOPBDUF.js → p-BPsKYMH6.js} +2 -2
  106. package/dist/components/{p-DkY7pIg5.js → p-BX1aRLEi.js} +95 -6
  107. package/dist/components/{p-BMRln59F.js → p-B_lZQo3s.js} +3 -3
  108. package/dist/components/{p-oGAPPwAL.js → p-BoN700-O.js} +2 -2
  109. package/dist/components/{p-BzJEwFtn.js → p-BozrueXV.js} +1 -1
  110. package/dist/components/{p-DwY5pghH.js → p-Bvv1pGFD.js} +1 -1
  111. package/dist/components/{p-D8bHNNEm.js → p-C6e7eOEb.js} +1238 -1241
  112. package/dist/components/{p-CM1duewg.js → p-CAoN_STZ.js} +2 -2
  113. package/dist/components/{p-CVhcOiEA.js → p-CM65T33m.js} +3 -3
  114. package/dist/components/{p-BknTMcbz.js → p-COylwYw1.js} +16 -16
  115. package/dist/components/{p-BYO6pz8T.js → p-CZ99VWQc.js} +1 -1
  116. package/dist/components/{p-DrQRChp3.js → p-CaBbajU2.js} +2 -2
  117. package/dist/components/{p-BzrxIesp.js → p-CrHvaLRV.js} +1 -1
  118. package/dist/components/{p-BKOsmXB5.js → p-D-UkGru5.js} +4 -4
  119. package/dist/components/{p-BVEGQLBD.js → p-DB58Oa5V.js} +2 -2
  120. package/dist/components/{p-yD_3Z3K1.js → p-DBqoPgev.js} +1 -1
  121. package/dist/components/{p-DIMNEOTW.js → p-DKGg-76-.js} +1 -1
  122. package/dist/components/{p-Dd-pMvlr.js → p-DPONTran.js} +11 -0
  123. package/dist/components/{p-DgnK50xO.js → p-DVXTPI71.js} +1 -1
  124. package/dist/components/{p-DgA865Tp.js → p-DdabFzSa.js} +5 -5
  125. package/dist/components/{p-CMZ09rB1.js → p-Dt7-I2Xz.js} +3 -3
  126. package/dist/components/{p-CwuGA058.js → p-DtnMdWWB.js} +1 -1
  127. package/dist/components/{p-7Rt7ml3v.js → p-Dv4pZx8G.js} +1 -1
  128. package/dist/components/{p-BO9BvTtK.js → p-DwMqCX2P.js} +2 -2
  129. package/dist/components/{p-Cr2z2UBL.js → p-Dy3uucl_.js} +7 -7
  130. package/dist/components/{p-Ckm1Aq0V.js → p-GKmqCI8r.js} +2 -2
  131. package/dist/components/{p-DSOc0eIT.js → p-PjPPCHVL.js} +1 -1
  132. package/dist/components/{p-OLfoATNk.js → p-ShJrZeyt.js} +1 -1
  133. package/dist/components/{p-0H4HOJjy.js → p-bAacqNHq.js} +11 -2
  134. package/dist/components/{p-BU-FZuj0.js → p-vqEUQ-4Q.js} +3 -3
  135. package/dist/components/{p-DDbO5q-e.js → p-x3z0Cz1B.js} +4 -4
  136. package/dist/esm/index.js +9 -2
  137. package/dist/esm/kritzel-active-users_47.entry.js +1427 -1289
  138. package/dist/esm/loader.js +1 -1
  139. package/dist/esm/{schema.constants-Rpm1NA9u.js → schema.constants-C-oQbvCY.js} +81 -7
  140. package/dist/esm/stencil.js +1 -1
  141. package/dist/stencil/index.esm.js +9 -2
  142. package/dist/stencil/{p-Rpm1NA9u.js → p-C-oQbvCY.js} +81 -7
  143. package/dist/stencil/{p-8c1b7b08.entry.js → p-be87f9d3.entry.js} +1427 -1289
  144. package/dist/stencil/stencil.esm.js +1 -1
  145. package/dist/types/classes/objects/image.class.d.ts +3 -0
  146. package/dist/types/classes/objects/path.class.d.ts +3 -0
  147. package/dist/types/classes/objects/shape.class.d.ts +3 -0
  148. package/dist/types/classes/providers/sync/broadcast-sync-provider.class.d.ts +1 -0
  149. package/dist/types/classes/providers/sync/hocuspocus-sync-provider.class.d.ts +1 -0
  150. package/dist/types/classes/providers/sync/in-memory-sync-provider.class.d.ts +1 -0
  151. package/dist/types/classes/providers/sync/indexeddb-sync-provider.class.d.ts +1 -0
  152. package/dist/types/classes/providers/sync/websocket-sync-provider.class.d.ts +1 -0
  153. package/dist/types/components/core/kritzel-editor/kritzel-editor.d.ts +17 -3
  154. package/dist/types/components/core/kritzel-engine/kritzel-engine.d.ts +16 -34
  155. package/dist/types/components/ui/kritzel-more-menu/kritzel-more-menu.d.ts +10 -1
  156. package/dist/types/components.d.ts +27 -26
  157. package/dist/types/constants/version.d.ts +1 -1
  158. package/dist/types/helpers/object.helper.d.ts +4 -0
  159. package/dist/types/index.d.ts +1 -0
  160. package/dist/types/interfaces/localization.interface.d.ts +1 -1
  161. package/dist/types/interfaces/menu-item.interface.d.ts +1 -1
  162. package/dist/types/interfaces/object-export.interface.d.ts +20 -0
  163. package/dist/types/interfaces/sync-config.interface.d.ts +5 -0
  164. package/dist/types/interfaces/sync-provider.interface.d.ts +5 -0
  165. package/dist/types/interfaces/theme.interface.d.ts +11 -5
  166. package/package.json +1 -1
@@ -1,19 +1,17 @@
1
1
  import { p as proxyCustomElement, H, c as createEvent, h, d as Host, t as transformTag } from './p-B43upypT.js';
2
- import { a as KritzelMouseButton, d as defineCustomElement$5 } from './p-Cm33td2O.js';
3
- import { K as KritzelBaseObject, e as KritzelMathHelper, f as KritzelGeometryHelper, a as KritzelPath, b as KritzelLine, d as defineCustomElement$6 } from './p-0H4HOJjy.js';
2
+ import { a as KritzelMouseButton, d as defineCustomElement$3 } from './p-Cm33td2O.js';
3
+ import { K as KritzelBaseObject, e as KritzelMathHelper, f as KritzelGeometryHelper, a as KritzelPath, b as KritzelLine, d as defineCustomElement$4 } from './p-bAacqNHq.js';
4
4
  import { c as commonjsGlobal } from './p-BFTU3MAI.js';
5
- import { a as KritzelColorHelper, D as DEFAULT_COLOR_PALETTE, K as KritzelThemeManager } from './p-CURiiQfq.js';
5
+ import { a as KritzelColorHelper, D as DEFAULT_COLOR_PALETTE, K as KritzelThemeManager } from './p-7I4Uzjrw.js';
6
6
  import { K as KritzelDevicesHelper } from './p-jGOpkGDl.js';
7
7
  import { K as KritzelFontRegistry, r as resolveTextToolAvailableFonts } from './p-DJkKlUyZ.js';
8
- import { A as ABSOLUTE_SCALE_MAX, a as ABSOLUTE_SCALE_MIN, d as defineCustomElement$1 } from './p-CwuGA058.js';
8
+ import { A as ABSOLUTE_SCALE_MAX, a as ABSOLUTE_SCALE_MIN, d as defineCustomElement$1 } from './p-DtnMdWWB.js';
9
9
  import { K as KritzelWorkspace, W as WORKSPACE_EXPORT_VERSION } from './p-DAwPPDMS.js';
10
10
  import { K as KritzelHTMLHelper } from './p-WUdQqYnu.js';
11
- import { O as ObjectHelper } from './p-Dd-pMvlr.js';
11
+ import { O as ObjectHelper } from './p-DPONTran.js';
12
12
  import * as Y from 'yjs';
13
13
  import { a as CURRENT_WORKSPACE_SCHEMA_VERSION, C as CURRENT_APP_STATE_SCHEMA_VERSION } from './p-CW-VyJgK.js';
14
- import { K as KritzelIconRegistry, d as defineCustomElement$4 } from './p-CdR76C4L.js';
15
- import { d as defineCustomElement$3 } from './p-BVEGQLBD.js';
16
- import { d as defineCustomElement$2 } from './p-CM1duewg.js';
14
+ import { K as KritzelIconRegistry, d as defineCustomElement$2 } from './p-CdR76C4L.js';
17
15
 
18
16
  class KritzelKeyboardHelper {
19
17
  static forceHideKeyboard() {
@@ -15453,6 +15451,15 @@ class KritzelImage extends KritzelBaseObject {
15453
15451
  }
15454
15452
  return { scaledWidth, scaledHeight };
15455
15453
  }
15454
+ getSupportedExportFormats() {
15455
+ return ['png'];
15456
+ }
15457
+ async exportAs(format, context) {
15458
+ if (format !== 'png') {
15459
+ return null;
15460
+ }
15461
+ return context.getObjectAsPngDataUrl(this);
15462
+ }
15456
15463
  }
15457
15464
 
15458
15465
  class KritzelClassHelper {
@@ -16660,6 +16667,15 @@ class KritzelShape extends KritzelBaseObject {
16660
16667
  return `M ${this.x} ${this.y} L ${this.x + w} ${this.y} L ${this.x + w} ${this.y + h} L ${this.x} ${this.y + h} Z`;
16661
16668
  }
16662
16669
  }
16670
+ getSupportedExportFormats() {
16671
+ return ['svg'];
16672
+ }
16673
+ async exportAs(format, context) {
16674
+ if (format !== 'svg') {
16675
+ return null;
16676
+ }
16677
+ return context.getObjectAsSvgDataUrl(this);
16678
+ }
16663
16679
  }
16664
16680
 
16665
16681
  const KRITZEL_RENDERER_REGISTRY_KEY = '__kritzelCustomElementRendererRegistry__';
@@ -23382,8 +23398,8 @@ const EN_LOCALE = {
23382
23398
  'toolConfig.expand': 'Expand',
23383
23399
  // More menu button
23384
23400
  'moreMenu.ariaLabel': 'More options',
23385
- // Engine
23386
- 'engine.loading': 'Loading...',
23401
+ // Editor
23402
+ 'editor.loading': 'Loading...',
23387
23403
  // Watermark
23388
23404
  'watermark.poweredBy': 'Powered by Kritzel',
23389
23405
  },
@@ -23502,8 +23518,8 @@ const DE_LOCALE = {
23502
23518
  'toolConfig.expand': 'Ausklappen',
23503
23519
  // More menu button
23504
23520
  'moreMenu.ariaLabel': 'Weitere Optionen',
23505
- // Engine
23506
- 'engine.loading': 'Wird geladen...',
23521
+ // Editor
23522
+ 'editor.loading': 'Wird geladen...',
23507
23523
  // Watermark
23508
23524
  'watermark.poweredBy': 'Bereitgestellt von Kritzel',
23509
23525
  },
@@ -23622,8 +23638,8 @@ const FR_LOCALE = {
23622
23638
  'toolConfig.expand': 'Développer',
23623
23639
  // More menu button
23624
23640
  'moreMenu.ariaLabel': "Plus d'options",
23625
- // Engine
23626
- 'engine.loading': 'Chargement...',
23641
+ // Editor
23642
+ 'editor.loading': 'Chargement...',
23627
23643
  // Watermark
23628
23644
  'watermark.poweredBy': 'Propulsé par Kritzel',
23629
23645
  },
@@ -47003,995 +47019,343 @@ class KritzelCore {
47003
47019
  }
47004
47020
  }
47005
47021
 
47006
- /**
47007
- * Helper class for exporting Kritzel objects to SVG format.
47008
- * Generates clean, standards-compliant SVG markup from canvas objects.
47009
- */
47010
- class KritzelSvgExportHelper {
47011
- /**
47012
- * Calculates the combined bounding box that encompasses all objects.
47013
- * Uses rotatedBoundingBox to account for object rotation.
47014
- * @param objects - Array of objects to calculate bounds for
47015
- * @returns Combined bounding box, or null if no objects provided
47016
- */
47017
- static calculateCombinedBounds(objects) {
47018
- if (!objects || objects.length === 0) {
47019
- return null;
47020
- }
47021
- let minX = Infinity;
47022
- let minY = Infinity;
47023
- let maxX = -Infinity;
47024
- let maxY = -Infinity;
47025
- for (const obj of objects) {
47026
- const bbox = obj.rotatedBoundingBox;
47027
- minX = Math.min(minX, bbox.x);
47028
- minY = Math.min(minY, bbox.y);
47029
- maxX = Math.max(maxX, bbox.x + bbox.width);
47030
- maxY = Math.max(maxY, bbox.y + bbox.height);
47031
- }
47032
- return {
47033
- x: minX,
47034
- y: minY,
47035
- z: 1,
47036
- width: maxX - minX,
47037
- height: maxY - minY,
47038
- };
47022
+ function resolveUrl(url, baseUrl) {
47023
+ // url is absolute already
47024
+ if (url.match(/^[a-z]+:\/\//i)) {
47025
+ return url;
47039
47026
  }
47040
- /**
47041
- * Generates a complete SVG document string from the given objects.
47042
- * @param objects - Objects to export
47043
- * @param options - Export options
47044
- * @returns SVG document string
47045
- */
47046
- static generateSvg(objects, options = {}) {
47047
- const { theme, padding = 0, includeXmlDeclaration = true, rasterSafeForCanvas = false, imageDataUrls } = options;
47048
- if (!objects || objects.length === 0) {
47049
- return '';
47050
- }
47051
- // Flatten groups to get all objects including nested children
47052
- const allObjects = this.flattenObjects(objects);
47053
- const bounds = this.calculateCombinedBounds(allObjects);
47054
- if (!bounds) {
47055
- return '';
47027
+ // url is absolute already, without protocol
47028
+ if (url.match(/^\/\//)) {
47029
+ return window.location.protocol + url;
47030
+ }
47031
+ // dataURI, mailto:, tel:, etc.
47032
+ if (url.match(/^[a-z]+:/i)) {
47033
+ return url;
47034
+ }
47035
+ const doc = document.implementation.createHTMLDocument();
47036
+ const base = doc.createElement('base');
47037
+ const a = doc.createElement('a');
47038
+ doc.head.appendChild(base);
47039
+ doc.body.appendChild(a);
47040
+ if (baseUrl) {
47041
+ base.href = baseUrl;
47042
+ }
47043
+ a.href = url;
47044
+ return a.href;
47045
+ }
47046
+ const uuid = (() => {
47047
+ // generate uuid for className of pseudo elements.
47048
+ // We should not use GUIDs, otherwise pseudo elements sometimes cannot be captured.
47049
+ let counter = 0;
47050
+ // ref: http://stackoverflow.com/a/6248722/2519373
47051
+ const random = () =>
47052
+ // eslint-disable-next-line no-bitwise
47053
+ `0000${((Math.random() * 36 ** 4) << 0).toString(36)}`.slice(-4);
47054
+ return () => {
47055
+ counter += 1;
47056
+ return `u${random()}${counter}`;
47057
+ };
47058
+ })();
47059
+ function toArray(arrayLike) {
47060
+ const arr = [];
47061
+ for (let i = 0, l = arrayLike.length; i < l; i++) {
47062
+ arr.push(arrayLike[i]);
47063
+ }
47064
+ return arr;
47065
+ }
47066
+ let styleProps = null;
47067
+ function getStyleProperties(options = {}) {
47068
+ if (styleProps) {
47069
+ return styleProps;
47070
+ }
47071
+ if (options.includeStyleProperties) {
47072
+ styleProps = options.includeStyleProperties;
47073
+ return styleProps;
47074
+ }
47075
+ styleProps = toArray(window.getComputedStyle(document.documentElement));
47076
+ return styleProps;
47077
+ }
47078
+ function px(node, styleProperty) {
47079
+ const win = node.ownerDocument.defaultView || window;
47080
+ const val = win.getComputedStyle(node).getPropertyValue(styleProperty);
47081
+ return val ? parseFloat(val.replace('px', '')) : 0;
47082
+ }
47083
+ function getNodeWidth(node) {
47084
+ const leftBorder = px(node, 'border-left-width');
47085
+ const rightBorder = px(node, 'border-right-width');
47086
+ return node.clientWidth + leftBorder + rightBorder;
47087
+ }
47088
+ function getNodeHeight(node) {
47089
+ const topBorder = px(node, 'border-top-width');
47090
+ const bottomBorder = px(node, 'border-bottom-width');
47091
+ return node.clientHeight + topBorder + bottomBorder;
47092
+ }
47093
+ function getImageSize(targetNode, options = {}) {
47094
+ const width = options.width || getNodeWidth(targetNode);
47095
+ const height = options.height || getNodeHeight(targetNode);
47096
+ return { width, height };
47097
+ }
47098
+ function getPixelRatio() {
47099
+ let ratio;
47100
+ let FINAL_PROCESS;
47101
+ try {
47102
+ FINAL_PROCESS = process;
47103
+ }
47104
+ catch (e) {
47105
+ // pass
47106
+ }
47107
+ const val = FINAL_PROCESS && FINAL_PROCESS.env
47108
+ ? FINAL_PROCESS.env.devicePixelRatio
47109
+ : null;
47110
+ if (val) {
47111
+ ratio = parseInt(val, 10);
47112
+ if (Number.isNaN(ratio)) {
47113
+ ratio = 1;
47056
47114
  }
47057
- // Add padding to bounds
47058
- const viewBoxX = bounds.x - padding;
47059
- const viewBoxY = bounds.y - padding;
47060
- const viewBoxWidth = bounds.width + padding * 2;
47061
- const viewBoxHeight = bounds.height + padding * 2;
47062
- // Collect all defs (markers, patterns, etc.)
47063
- const defs = this.collectDefs(allObjects, theme);
47064
- // Generate SVG elements for each object
47065
- const elements = objects
47066
- .sort((a, b) => a.zIndex - b.zIndex)
47067
- .map(obj => this.objectToSvgElement(obj, theme, imageDataUrls, rasterSafeForCanvas))
47068
- .filter(Boolean)
47069
- .join('\n ');
47070
- const xmlDecl = includeXmlDeclaration ? '<?xml version="1.0" encoding="UTF-8"?>\n' : '';
47071
- const defsContent = defs.length > 0 ? `\n <defs>\n ${defs.join('\n ')}\n </defs>` : '';
47072
- return `${xmlDecl}<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBoxX} ${viewBoxY} ${viewBoxWidth} ${viewBoxHeight}" width="${viewBoxWidth}" height="${viewBoxHeight}">${defsContent}
47073
- ${elements}
47074
- </svg>`;
47075
47115
  }
47076
- /**
47077
- * Flattens a list of objects, expanding groups into their children recursively.
47078
- * @param objects - Objects to flatten
47079
- * @returns Flat array of all objects including group children
47080
- */
47081
- static flattenObjects(objects) {
47082
- const result = [];
47083
- for (const obj of objects) {
47084
- if (KritzelClassHelper.isInstanceOf(obj, 'KritzelGroup')) {
47085
- result.push(...this.flattenObjects(obj.children));
47116
+ return ratio || window.devicePixelRatio || 1;
47117
+ }
47118
+ // @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#maximum_canvas_size
47119
+ const canvasDimensionLimit = 16384;
47120
+ function checkCanvasDimensions(canvas) {
47121
+ if (canvas.width > canvasDimensionLimit ||
47122
+ canvas.height > canvasDimensionLimit) {
47123
+ if (canvas.width > canvasDimensionLimit &&
47124
+ canvas.height > canvasDimensionLimit) {
47125
+ if (canvas.width > canvas.height) {
47126
+ canvas.height *= canvasDimensionLimit / canvas.width;
47127
+ canvas.width = canvasDimensionLimit;
47086
47128
  }
47087
47129
  else {
47088
- result.push(obj);
47130
+ canvas.width *= canvasDimensionLimit / canvas.height;
47131
+ canvas.height = canvasDimensionLimit;
47089
47132
  }
47090
47133
  }
47091
- return result;
47092
- }
47093
- /**
47094
- * Resolves every image referenced by the given objects (including those
47095
- * nested inside groups) to an inlined `data:` URL, keyed by object id.
47096
- *
47097
- * This is required for export flows that rasterize the SVG (e.g. PNG
47098
- * export): an SVG loaded into an `<img>` element will NOT load external
47099
- * or `blob:` resources, so a `<image href="blob:...">` renders as a
47100
- * broken-image placeholder. Embedding the bytes as a base64 `data:` URL
47101
- * makes the image self-contained and survive rasterization.
47102
- *
47103
- * Resolution is best-effort: images that fail to resolve are simply
47104
- * omitted from the map, and the SVG falls back to whatever `href` the
47105
- * synchronous path would have produced.
47106
- * @param objects - Objects to inline images for
47107
- * @param assetResolver - Resolver used to fetch asset bytes; optional
47108
- * @returns Map of image object id -> inlined `data:` URL
47109
- */
47110
- static async resolveImageDataUrls(objects, assetResolver) {
47111
- const dataUrls = new Map();
47112
- if (!objects || objects.length === 0) {
47113
- return dataUrls;
47134
+ else if (canvas.width > canvasDimensionLimit) {
47135
+ canvas.height *= canvasDimensionLimit / canvas.width;
47136
+ canvas.width = canvasDimensionLimit;
47114
47137
  }
47115
- const images = this.flattenObjects(objects).filter((obj) => KritzelClassHelper.isInstanceOf(obj, 'KritzelImage'));
47116
- await Promise.all(images.map(async (image) => {
47117
- // Legacy inline data URL is already embeddable as-is.
47118
- if (image.src?.startsWith('data:')) {
47119
- dataUrls.set(image.id, image.src);
47120
- return;
47121
- }
47122
- // A resolvedSrc that is itself a data URL can be embedded directly.
47123
- if (image.resolvedSrc?.startsWith('data:')) {
47124
- dataUrls.set(image.id, image.resolvedSrc);
47125
- return;
47126
- }
47127
- if (!image.assetId || !assetResolver) {
47128
- return;
47129
- }
47138
+ else {
47139
+ canvas.width *= canvasDimensionLimit / canvas.height;
47140
+ canvas.height = canvasDimensionLimit;
47141
+ }
47142
+ }
47143
+ }
47144
+ function createImage(url) {
47145
+ return new Promise((resolve, reject) => {
47146
+ const img = new Image();
47147
+ img.onload = () => {
47148
+ img.decode().then(() => {
47149
+ requestAnimationFrame(() => resolve(img));
47150
+ });
47151
+ };
47152
+ img.onerror = reject;
47153
+ img.crossOrigin = 'anonymous';
47154
+ img.decoding = 'async';
47155
+ img.src = url;
47156
+ });
47157
+ }
47158
+ async function svgToDataURL(svg) {
47159
+ return Promise.resolve()
47160
+ .then(() => new XMLSerializer().serializeToString(svg))
47161
+ .then(encodeURIComponent)
47162
+ .then((html) => `data:image/svg+xml;charset=utf-8,${html}`);
47163
+ }
47164
+ async function nodeToDataURL(node, width, height) {
47165
+ const xmlns = 'http://www.w3.org/2000/svg';
47166
+ const svg = document.createElementNS(xmlns, 'svg');
47167
+ const foreignObject = document.createElementNS(xmlns, 'foreignObject');
47168
+ svg.setAttribute('width', `${width}`);
47169
+ svg.setAttribute('height', `${height}`);
47170
+ svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
47171
+ foreignObject.setAttribute('width', '100%');
47172
+ foreignObject.setAttribute('height', '100%');
47173
+ foreignObject.setAttribute('x', '0');
47174
+ foreignObject.setAttribute('y', '0');
47175
+ foreignObject.setAttribute('externalResourcesRequired', 'true');
47176
+ svg.appendChild(foreignObject);
47177
+ foreignObject.appendChild(node);
47178
+ return svgToDataURL(svg);
47179
+ }
47180
+ const isInstanceOfElement = (node, instance) => {
47181
+ if (node instanceof instance)
47182
+ return true;
47183
+ const nodePrototype = Object.getPrototypeOf(node);
47184
+ if (nodePrototype === null)
47185
+ return false;
47186
+ return (nodePrototype.constructor.name === instance.name ||
47187
+ isInstanceOfElement(nodePrototype, instance));
47188
+ };
47189
+
47190
+ function formatCSSText(style) {
47191
+ const content = style.getPropertyValue('content');
47192
+ return `${style.cssText} content: '${content.replace(/'|"/g, '')}';`;
47193
+ }
47194
+ function formatCSSProperties(style, options) {
47195
+ return getStyleProperties(options)
47196
+ .map((name) => {
47197
+ const value = style.getPropertyValue(name);
47198
+ const priority = style.getPropertyPriority(name);
47199
+ return `${name}: ${value}${priority ? ' !important' : ''};`;
47200
+ })
47201
+ .join(' ');
47202
+ }
47203
+ function getPseudoElementStyle(className, pseudo, style, options) {
47204
+ const selector = `.${className}:${pseudo}`;
47205
+ const cssText = style.cssText
47206
+ ? formatCSSText(style)
47207
+ : formatCSSProperties(style, options);
47208
+ return document.createTextNode(`${selector}{${cssText}}`);
47209
+ }
47210
+ function clonePseudoElement(nativeNode, clonedNode, pseudo, options) {
47211
+ const style = window.getComputedStyle(nativeNode, pseudo);
47212
+ const content = style.getPropertyValue('content');
47213
+ if (content === '' || content === 'none') {
47214
+ return;
47215
+ }
47216
+ const className = uuid();
47217
+ try {
47218
+ clonedNode.className = `${clonedNode.className} ${className}`;
47219
+ }
47220
+ catch (err) {
47221
+ return;
47222
+ }
47223
+ const styleElement = document.createElement('style');
47224
+ styleElement.appendChild(getPseudoElementStyle(className, pseudo, style, options));
47225
+ clonedNode.appendChild(styleElement);
47226
+ }
47227
+ function clonePseudoElements(nativeNode, clonedNode, options) {
47228
+ clonePseudoElement(nativeNode, clonedNode, ':before', options);
47229
+ clonePseudoElement(nativeNode, clonedNode, ':after', options);
47230
+ }
47231
+
47232
+ const WOFF = 'application/font-woff';
47233
+ const JPEG = 'image/jpeg';
47234
+ const mimes = {
47235
+ woff: WOFF,
47236
+ woff2: WOFF,
47237
+ ttf: 'application/font-truetype',
47238
+ eot: 'application/vnd.ms-fontobject',
47239
+ png: 'image/png',
47240
+ jpg: JPEG,
47241
+ jpeg: JPEG,
47242
+ gif: 'image/gif',
47243
+ tiff: 'image/tiff',
47244
+ svg: 'image/svg+xml',
47245
+ webp: 'image/webp',
47246
+ };
47247
+ function getExtension(url) {
47248
+ const match = /\.([^./]*?)$/g.exec(url);
47249
+ return match ? match[1] : '';
47250
+ }
47251
+ function getMimeType(url) {
47252
+ const extension = getExtension(url).toLowerCase();
47253
+ return mimes[extension] || '';
47254
+ }
47255
+
47256
+ function getContentFromDataUrl(dataURL) {
47257
+ return dataURL.split(/,/)[1];
47258
+ }
47259
+ function isDataUrl(url) {
47260
+ return url.search(/^(data:)/) !== -1;
47261
+ }
47262
+ function makeDataUrl(content, mimeType) {
47263
+ return `data:${mimeType};base64,${content}`;
47264
+ }
47265
+ async function fetchAsDataURL(url, init, process) {
47266
+ const res = await fetch(url, init);
47267
+ if (res.status === 404) {
47268
+ throw new Error(`Resource "${res.url}" not found`);
47269
+ }
47270
+ const blob = await res.blob();
47271
+ return new Promise((resolve, reject) => {
47272
+ const reader = new FileReader();
47273
+ reader.onerror = reject;
47274
+ reader.onloadend = () => {
47130
47275
  try {
47131
- const blob = await assetResolver.fetchBlob(image.assetId);
47132
- dataUrls.set(image.id, await this.blobToDataUrl(blob));
47276
+ resolve(process({ res, result: reader.result }));
47133
47277
  }
47134
- catch (err) {
47135
- console.warn(`[KritzelSvgExportHelper] Failed to inline image asset ${image.assetId} for export:`, err);
47278
+ catch (error) {
47279
+ reject(error);
47136
47280
  }
47137
- }));
47138
- return dataUrls;
47281
+ };
47282
+ reader.readAsDataURL(blob);
47283
+ });
47284
+ }
47285
+ const cache = {};
47286
+ function getCacheKey(url, contentType, includeQueryParams) {
47287
+ let key = url.replace(/\?.*/, '');
47288
+ if (includeQueryParams) {
47289
+ key = url;
47139
47290
  }
47140
- /**
47141
- * Reads a Blob into a base64 `data:` URL.
47142
- * @param blob - The blob to encode
47143
- * @returns Promise resolving to the data URL string
47144
- */
47145
- static async blobToDataUrl(blob) {
47146
- // Prefer FileReader in browser-like runtimes for efficiency.
47147
- const hasFileReader = typeof FileReader !== 'undefined';
47148
- if (hasFileReader) {
47149
- try {
47150
- return await new Promise((resolve, reject) => {
47151
- const reader = new FileReader();
47152
- reader.onload = () => {
47153
- if (typeof reader.result === 'string') {
47154
- resolve(reader.result);
47155
- return;
47156
- }
47157
- reject(new Error('FileReader result is not a string'));
47158
- };
47159
- reader.onerror = () => reject(reader.error ?? new Error('Failed to read blob as data URL'));
47160
- reader.readAsDataURL(blob);
47161
- });
47162
- }
47163
- catch {
47164
- // Fall through to the ArrayBuffer-based encoder.
47291
+ // font resource
47292
+ if (/ttf|otf|eot|woff2?/i.test(key)) {
47293
+ key = key.replace(/.*\//, '');
47294
+ }
47295
+ return contentType ? `[${contentType}]${key}` : key;
47296
+ }
47297
+ async function resourceToDataURL(resourceUrl, contentType, options) {
47298
+ const cacheKey = getCacheKey(resourceUrl, contentType, options.includeQueryParams);
47299
+ if (cache[cacheKey] != null) {
47300
+ return cache[cacheKey];
47301
+ }
47302
+ // ref: https://developer.mozilla.org/en/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest#Bypassing_the_cache
47303
+ if (options.cacheBust) {
47304
+ // eslint-disable-next-line no-param-reassign
47305
+ resourceUrl += (/\?/.test(resourceUrl) ? '&' : '?') + new Date().getTime();
47306
+ }
47307
+ let dataURL;
47308
+ try {
47309
+ const content = await fetchAsDataURL(resourceUrl, options.fetchRequestInit, ({ res, result }) => {
47310
+ if (!contentType) {
47311
+ // eslint-disable-next-line no-param-reassign
47312
+ contentType = res.headers.get('Content-Type') || '';
47165
47313
  }
47166
- }
47167
- const bytes = new Uint8Array(await blob.arrayBuffer());
47168
- const mimeType = blob.type || 'application/octet-stream';
47169
- return `data:${mimeType};base64,${this.uint8ToBase64(bytes)}`;
47314
+ return getContentFromDataUrl(result);
47315
+ });
47316
+ dataURL = makeDataUrl(content, contentType);
47170
47317
  }
47171
- /**
47172
- * Encodes a byte array to base64 across browser and Node-like runtimes.
47173
- */
47174
- static uint8ToBase64(bytes) {
47175
- if (typeof Buffer !== 'undefined') {
47176
- return Buffer.from(bytes).toString('base64');
47177
- }
47178
- let binary = '';
47179
- const chunkSize = 0x8000;
47180
- for (let i = 0; i < bytes.length; i += chunkSize) {
47181
- const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length));
47182
- binary += String.fromCharCode(...chunk);
47318
+ catch (error) {
47319
+ dataURL = options.imagePlaceholder || '';
47320
+ let msg = `Failed to fetch resource: ${resourceUrl}`;
47321
+ if (error) {
47322
+ msg = typeof error === 'string' ? error : error.message;
47183
47323
  }
47184
- if (typeof btoa === 'function') {
47185
- return btoa(binary);
47324
+ if (msg) {
47325
+ console.warn(msg);
47186
47326
  }
47187
- throw new Error('No base64 encoder available in this runtime');
47188
47327
  }
47189
- /**
47190
- * Collects SVG defs (markers, patterns, etc.) needed by the objects.
47191
- * @param objects - Objects to collect defs for
47192
- * @param theme - Theme for color resolution
47193
- * @returns Array of def element strings
47194
- */
47195
- static collectDefs(objects, theme) {
47196
- const defs = [];
47197
- for (const obj of objects) {
47198
- if (KritzelClassHelper.isInstanceOf(obj, 'KritzelLine')) {
47199
- if (obj.hasStartArrow) {
47200
- defs.push(this.generateArrowMarker(obj, 'start', theme));
47201
- }
47202
- if (obj.hasEndArrow) {
47203
- defs.push(this.generateArrowMarker(obj, 'end', theme));
47204
- }
47205
- }
47206
- }
47207
- return defs;
47208
- }
47209
- /**
47210
- * Generates an SVG marker element for line arrows.
47211
- * @param line - The line object
47212
- * @param end - Which end of the line ('start' or 'end')
47213
- * @param theme - Theme for color resolution
47214
- * @returns SVG marker element string
47215
- */
47216
- static generateArrowMarker(line, end, _theme) {
47217
- const markerId = end === 'start' ? line.startMarkerId : line.endMarkerId;
47218
- const size = line.getArrowSize(end);
47219
- const fill = line.getArrowFill(end);
47220
- const style = end === 'start' ? line.arrows?.start?.style : line.arrows?.end?.style;
47221
- const path = line.getArrowPath(style);
47222
- const orient = end === 'start' ? 'auto-start-reverse' : 'auto';
47223
- const refX = 0;
47224
- const refY = size / 2;
47225
- return `<marker id="${markerId}" markerWidth="${size}" markerHeight="${size}" refX="${refX}" refY="${refY}" orient="${orient}" markerUnits="userSpaceOnUse">
47226
- <path d="${path}" fill="${fill}" transform="scale(${size / 10})"/>
47227
- </marker>`;
47228
- }
47229
- /**
47230
- * Converts a Kritzel object to its SVG element representation.
47231
- * @param object - The object to convert
47232
- * @param theme - Theme for color resolution
47233
- * @param imageDataUrls - Optional map of image id -> inlined data URL
47234
- * @returns SVG element string, or empty string if object type is not supported
47235
- */
47236
- static objectToSvgElement(object, theme, imageDataUrls, rasterSafeForCanvas = false) {
47237
- if (KritzelClassHelper.isInstanceOf(object, 'KritzelPath')) {
47238
- return this.pathToSvg(object, theme);
47239
- }
47240
- if (KritzelClassHelper.isInstanceOf(object, 'KritzelLine')) {
47241
- return this.lineToSvg(object, theme);
47242
- }
47243
- if (KritzelClassHelper.isInstanceOf(object, 'KritzelShape')) {
47244
- return this.shapeToSvg(object, theme);
47245
- }
47246
- if (KritzelClassHelper.isInstanceOf(object, 'KritzelText')) {
47247
- return this.textToSvg(object, theme, rasterSafeForCanvas);
47248
- }
47249
- if (KritzelClassHelper.isInstanceOf(object, 'KritzelImage')) {
47250
- return this.imageToSvg(object, imageDataUrls);
47251
- }
47252
- if (KritzelClassHelper.isInstanceOf(object, 'KritzelCustomElement')) {
47253
- return this.customElementToSvg(object, rasterSafeForCanvas);
47254
- }
47255
- if (KritzelClassHelper.isInstanceOf(object, 'KritzelGroup')) {
47256
- return this.groupToSvg(object, theme, imageDataUrls, rasterSafeForCanvas);
47257
- }
47258
- return '';
47259
- }
47260
- /**
47261
- * Converts a KritzelPath to SVG.
47262
- * @param path - The path object
47263
- * @param theme - Theme for color resolution
47264
- * @returns SVG element string
47265
- */
47266
- static pathToSvg(path, theme) {
47267
- const transform = this.buildTransform(path);
47268
- const fill = KritzelColorHelper.resolveThemeColor(path.fill, theme);
47269
- const stroke = KritzelColorHelper.resolveThemeColor(path.stroke, theme);
47270
- const opacity = path.opacity !== 1 ? ` opacity="${path.opacity}"` : '';
47271
- const scale = this.getObjectScale(path);
47272
- // Path needs to be wrapped in a g with translation since path.d uses local coordinates
47273
- return `<g transform="${transform}"${opacity}>
47274
- <svg viewBox="${path.viewBox}" width="${path.totalWidth / scale}" height="${path.totalHeight / scale}" overflow="visible">
47275
- <path d="${path.d}" fill="${fill || 'none'}" stroke="${stroke || 'none'}"${path.strokeWidth ? ` stroke-width="${path.strokeWidth}"` : ''}/>
47276
- </svg>
47277
- </g>`;
47278
- }
47279
- /**
47280
- * Converts a KritzelLine to SVG.
47281
- * @param line - The line object
47282
- * @param theme - Theme for color resolution
47283
- * @returns SVG element string
47284
- */
47285
- static lineToSvg(line, theme) {
47286
- const transform = this.buildTransform(line);
47287
- const stroke = KritzelColorHelper.resolveThemeColor(line.stroke, theme);
47288
- const opacity = line.opacity !== 1 ? ` opacity="${line.opacity}"` : '';
47289
- const scale = this.getObjectScale(line);
47290
- const markerStart = line.hasStartArrow ? ` marker-start="url(#${line.startMarkerId})"` : '';
47291
- const markerEnd = line.hasEndArrow ? ` marker-end="url(#${line.endMarkerId})"` : '';
47292
- return `<g transform="${transform}"${opacity}>
47293
- <svg viewBox="${line.viewBox}" width="${line.totalWidth / scale}" height="${line.totalHeight / scale}" overflow="visible">
47294
- <path d="${line.d}" fill="none" stroke="${stroke}" stroke-width="${line.strokeWidth}" stroke-linecap="round"${markerStart}${markerEnd}/>
47295
- </svg>
47296
- </g>`;
47297
- }
47298
- /**
47299
- * Converts a KritzelShape to SVG.
47300
- * @param shape - The shape object
47301
- * @param theme - Theme for color resolution
47302
- * @returns SVG element string
47303
- */
47304
- static shapeToSvg(shape, theme) {
47305
- const transform = this.buildTransform(shape);
47306
- const fill = KritzelColorHelper.resolveThemeColor(shape.fillColor, theme);
47307
- const stroke = KritzelColorHelper.resolveThemeColor(shape.strokeColor, theme);
47308
- const opacity = shape.opacity !== 1 ? ` opacity="${shape.opacity}"` : '';
47309
- const scale = this.getObjectScale(shape);
47310
- // Get the SVG path for the shape
47311
- const pathD = shape.getSvgPath();
47312
- // Handle text content if present
47313
- let textContent = '';
47314
- if (shape.editor && !shape.editor.state.doc.textContent.trim()) ;
47315
- else if (shape.content) {
47316
- const textHtml = this.prosemirrorToHtml(shape.content, shape, theme);
47317
- if (textHtml) {
47318
- const fontColor = KritzelColorHelper.resolveThemeColor(shape.fontColor, theme);
47319
- textContent = `
47320
- <foreignObject x="${shape.x}" y="${shape.y}" width="${shape.width}" height="${shape.height}">
47321
- <div xmlns="http://www.w3.org/1999/xhtml" style="width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; text-align: center; font-family: ${shape.fontFamily}; font-size: ${shape.fontSize}px; color: ${fontColor}; overflow: hidden;">
47322
- ${textHtml}
47323
- </div>
47324
- </foreignObject>`;
47325
- }
47326
- }
47327
- return `<g transform="${transform}"${opacity}>
47328
- <svg viewBox="${shape.viewBox}" width="${shape.totalWidth / scale}" height="${shape.totalHeight / scale}" overflow="visible" preserveAspectRatio="none">
47329
- <path d="${pathD}" fill="${fill || 'transparent'}" stroke="${stroke}" stroke-width="${shape.strokeWidth}"/>${textContent}
47330
- </svg>
47331
- </g>`;
47332
- }
47333
- /**
47334
- * Converts a KritzelText to SVG using foreignObject for rich text support.
47335
- * @param text - The text object
47336
- * @param theme - Theme for color resolution
47337
- * @returns SVG element string
47338
- */
47339
- static textToSvg(text, theme, rasterSafeForCanvas = false) {
47340
- if (rasterSafeForCanvas) {
47341
- return this.textToSvgRasterSafe(text, theme);
47342
- }
47343
- const transform = this.buildTransform(text);
47344
- const opacity = text.opacity !== 1 ? ` opacity="${text.opacity}"` : '';
47345
- const fontColor = KritzelColorHelper.resolveThemeColor(text.fontColor, theme);
47346
- const bgColor = KritzelColorHelper.resolveThemeColor(text.backgroundColor, theme);
47347
- const scale = this.getObjectScale(text);
47348
- // Convert ProseMirror content to HTML
47349
- const htmlContent = this.prosemirrorToHtml(text.content, text, theme);
47350
- // Calculate dimensions in SVG world units (world units = CSS px in a 1:1 SVG).
47351
- const width = text.totalWidth / scale;
47352
- const height = text.totalHeight / scale;
47353
- // Convert the font size from pt to SVG world units (px equivalent).
47354
- // text.fontSize is stored in CSS pt; 1 pt = 4/3 px at 96 dpi.
47355
- // We also bake in scaleFactor (user resize) and divide by the object scale so
47356
- // that the rendered font aligns with the world-unit dimensions above.
47357
- // The CSS transform: scale(scaleFactor) is removed from the div because the
47358
- // adjusted font-size already accounts for it – keeping both would double-scale
47359
- // the content and overflow the foreignObject.
47360
- const svgFontSizePx = (text.fontSize * (4 / 3) * (text.scaleFactor || 1) / scale).toFixed(4);
47361
- return `<g transform="${transform}"${opacity}>
47362
- <foreignObject x="0" y="0" width="${width}" height="${height}" overflow="visible">
47363
- <div xmlns="http://www.w3.org/1999/xhtml" style="font-family: ${text.fontFamily}; font-size: ${svgFontSizePx}px; color: ${fontColor}; background-color: ${bgColor || 'transparent'}; white-space: pre-wrap; word-wrap: break-word; width: 100%; margin: 0; padding: 0; box-sizing: border-box;">${htmlContent.trim()}</div>
47364
- </foreignObject>
47365
- </g>`;
47366
- }
47367
- /**
47368
- * Converts a KritzelText to SVG using pure SVG text primitives.
47369
- * This path avoids `<foreignObject>`, which taints canvases when the
47370
- * generated SVG is rasterized through `<img>` and `drawImage`.
47371
- */
47372
- static textToSvgRasterSafe(text, theme) {
47373
- const transform = this.buildTransform(text);
47374
- const opacity = text.opacity !== 1 ? ` opacity="${text.opacity}"` : '';
47375
- const fontColor = KritzelColorHelper.resolveThemeColor(text.fontColor, theme);
47376
- const bgColor = KritzelColorHelper.resolveThemeColor(text.backgroundColor, theme);
47377
- const scale = this.getObjectScale(text);
47378
- const width = text.totalWidth / scale;
47379
- const height = text.totalHeight / scale;
47380
- const lines = this.prosemirrorToPlainTextLines(text.content);
47381
- const resolvedLines = lines.length > 0 ? lines : [''];
47382
- // Convert the font size from pt to SVG world units (px equivalent).
47383
- // text.fontSize is stored in CSS pt; 1 pt = 4/3 px at 96 dpi.
47384
- // We also bake in scaleFactor (user resize) and divide by the object scale so
47385
- // that the rendered lines align with the world-unit bounding box dimensions above.
47386
- const resolvedFontSizePx = Math.max(1, text.fontSize * (4 / 3) * (text.scaleFactor || 1) / scale);
47387
- const lineHeight = resolvedFontSizePx * 1.2;
47388
- const startY = resolvedFontSizePx;
47389
- const bgRect = bgColor && bgColor !== 'transparent'
47390
- ? `<rect x="0" y="0" width="${width}" height="${height}" fill="${bgColor}"/>`
47391
- : '';
47392
- const tspans = resolvedLines
47393
- .map((line, index) => {
47394
- const y = startY + index * lineHeight;
47395
- return `<tspan x="0" y="${y}" xml:space="preserve">${this.escapeHtml(line)}</tspan>`;
47396
- })
47397
- .join('');
47398
- return `<g transform="${transform}"${opacity}>
47399
- ${bgRect}
47400
- <text font-family="${this.escapeHtml(text.fontFamily)}" font-size="${resolvedFontSizePx}" fill="${fontColor}">${tspans}</text>
47401
- </g>`;
47402
- }
47403
- /**
47404
- * Converts a KritzelImage to SVG.
47405
- *
47406
- * Prefers an inlined `data:` URL from `imageDataUrls` (keyed by object
47407
- * id) when provided, so the embedded bytes survive rasterization to
47408
- * PNG. Falls back to `resolvedSrc` (populated by the renderer via the
47409
- * asset resolver) and finally the legacy inline `src` field for
47410
- * documents persisted before the asset layer existed.
47411
- *
47412
- * Note: a `blob:` `resolvedSrc` renders correctly on-canvas but becomes
47413
- * a broken-image placeholder once the SVG is loaded into an `<img>` and
47414
- * drawn to a canvas. Callers that rasterize the SVG must pass
47415
- * `imageDataUrls` (see {@link KritzelSvgExportHelper.resolveImageDataUrls}).
47416
- * @param image - The image object
47417
- * @param imageDataUrls - Optional map of image id -> inlined data URL
47418
- * @returns SVG element string
47419
- */
47420
- static imageToSvg(image, imageDataUrls) {
47421
- const transform = this.buildTransform(image);
47422
- const opacity = image.opacity !== 1 ? ` opacity="${image.opacity}"` : '';
47423
- const scale = this.getObjectScale(image);
47424
- // Calculate dimensions
47425
- const width = image.totalWidth / scale;
47426
- const height = image.totalHeight / scale;
47427
- const href = imageDataUrls?.get(image.id) || image.resolvedSrc || image.src || '';
47428
- return `<g transform="${transform}"${opacity}>
47429
- <image href="${href}" x="0" y="0" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet"/>
47430
- </g>`;
47431
- }
47432
- /**
47433
- * Converts a KritzelCustomElement to SVG.
47434
- * SVG mode: embeds HTML in foreignObject (best-effort).
47435
- * PNG/raster mode: renders placeholder rect with warning.
47436
- * @param obj - The custom element object
47437
- * @param rasterSafeForCanvas - If true, render placeholder (for PNG export)
47438
- * @returns SVG element string
47439
- */
47440
- static customElementToSvg(obj, rasterSafeForCanvas = false) {
47441
- const transform = this.buildTransform(obj);
47442
- const opacity = obj.opacity !== 1 ? ` opacity="${obj.opacity}"` : '';
47443
- const scale = this.getObjectScale(obj);
47444
- const width = obj.totalWidth / scale;
47445
- const height = obj.totalHeight / scale;
47446
- if (rasterSafeForCanvas) {
47447
- // PNG export: render placeholder rect (foreignObject doesn't rasterize to PNG well)
47448
- console.warn('[KritzelCustomElement SVG Export] Custom elements do not serialize well to PNG. Use SVG export or screenshot instead.');
47449
- return `<g transform="${transform}"${opacity}>
47450
- <rect x="0" y="0" width="${width}" height="${height}" fill="#f0f0f0" stroke="#999" stroke-width="1" />
47451
- <text x="${width / 2}" y="${height / 2}" text-anchor="middle" dominant-baseline="middle" font-size="12" fill="#999">[Custom Element]</text>
47452
- </g>`;
47453
- }
47454
- // SVG export: use foreignObject to embed HTML
47455
- const html = obj.element?.outerHTML || '<div style="border:1px dashed #ccc">Empty</div>';
47456
- return `<g transform="${transform}"${opacity}>
47457
- <foreignObject x="0" y="0" width="${width}" height="${height}">
47458
- <div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;overflow:hidden;">
47459
- ${html}
47460
- </div>
47461
- </foreignObject>
47462
- </g>`;
47463
- }
47464
- /**
47465
- * Converts a KritzelGroup to SVG by recursively converting children.
47466
- * @param group - The group object
47467
- * @param theme - Theme for color resolution
47468
- * @param imageDataUrls - Optional map of image id -> inlined data URL
47469
- * @returns SVG element string
47470
- */
47471
- static groupToSvg(group, theme, imageDataUrls, rasterSafeForCanvas = false) {
47472
- const children = group.children
47473
- .sort((a, b) => a.zIndex - b.zIndex)
47474
- .map(child => this.objectToSvgElement(child, theme, imageDataUrls, rasterSafeForCanvas))
47475
- .filter(Boolean)
47476
- .join('\n ');
47477
- if (!children) {
47478
- return '';
47479
- }
47480
- const opacity = group.opacity !== 1 ? ` opacity="${group.opacity}"` : '';
47481
- return `<g${opacity}>
47482
- ${children}
47483
- </g>`;
47484
- }
47485
- /**
47486
- * Builds a transform attribute string for an object.
47487
- * Combines translation and rotation.
47488
- * @param object - The object to build transform for
47489
- * @returns Transform attribute value string
47490
- */
47491
- static buildTransform(object) {
47492
- const transforms = [];
47493
- const scale = this.getObjectScale(object);
47494
- // Translation
47495
- transforms.push(`translate(${object.translateX}, ${object.translateY})`);
47496
- // Rotation around center
47497
- if (object.rotation !== 0) {
47498
- const centerX = object.totalWidth / 2 / scale;
47499
- const centerY = object.totalHeight / 2 / scale;
47500
- const degrees = object.rotation * (180 / Math.PI);
47501
- transforms.push(`rotate(${degrees}, ${centerX}, ${centerY})`);
47502
- }
47503
- return transforms.join(' ');
47504
- }
47505
- /**
47506
- * Returns a safe non-zero scale for export geometry calculations.
47507
- */
47508
- static getObjectScale(object) {
47509
- const scale = object.scale ?? 1;
47510
- return scale === 0 ? 1 : scale;
47511
- }
47512
- /**
47513
- * Converts ProseMirror JSON content to HTML string.
47514
- * @param content - ProseMirror document JSON
47515
- * @param context - The text or shape object for styling context
47516
- * @param theme - Theme for color resolution
47517
- * @returns HTML string
47518
- */
47519
- static prosemirrorToHtml(content, _context, theme) {
47520
- if (!content || !content.content) {
47521
- return '';
47522
- }
47523
- return content.content
47524
- .map((node) => this.nodeToHtml(node, theme))
47525
- .join('');
47526
- }
47527
- /**
47528
- * Converts a ProseMirror node to HTML.
47529
- * @param node - ProseMirror node JSON
47530
- * @param theme - Theme for color resolution
47531
- * @returns HTML string
47532
- */
47533
- static nodeToHtml(node, theme) {
47534
- if (!node)
47535
- return '';
47536
- switch (node.type) {
47537
- case 'paragraph':
47538
- const pContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
47539
- return `<p style="margin: 0;">${pContent || '<br/>'}</p>`;
47540
- case 'text':
47541
- let text = this.escapeHtml(node.text || '');
47542
- if (node.marks) {
47543
- for (const mark of node.marks) {
47544
- text = this.applyMark(text, mark, theme);
47545
- }
47546
- }
47547
- return text;
47548
- case 'hard_break':
47549
- return '<br/>';
47550
- case 'bullet_list':
47551
- const blContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
47552
- return `<ul style="margin: 0; padding-left: 1.5em;">${blContent}</ul>`;
47553
- case 'ordered_list':
47554
- const olContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
47555
- return `<ol style="margin: 0; padding-left: 1.5em;">${olContent}</ol>`;
47556
- case 'list_item':
47557
- const liContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
47558
- return `<li>${liContent}</li>`;
47559
- default:
47560
- // For unknown node types, try to render content
47561
- if (node.content) {
47562
- return node.content.map((n) => this.nodeToHtml(n, theme)).join('');
47563
- }
47564
- return '';
47565
- }
47566
- }
47567
- /**
47568
- * Converts ProseMirror JSON content to plain-text lines.
47569
- * Used by raster-safe SVG export, which cannot depend on HTML/foreignObject.
47570
- */
47571
- static prosemirrorToPlainTextLines(content) {
47572
- if (!content || !Array.isArray(content.content)) {
47573
- return [];
47574
- }
47575
- const plainText = content.content
47576
- .map((node) => this.nodeToPlainText(node))
47577
- .join('')
47578
- .replace(/\r\n/g, '\n')
47579
- .replace(/\n+$/, '');
47580
- if (!plainText) {
47581
- return [];
47582
- }
47583
- return plainText.split('\n');
47584
- }
47585
- /**
47586
- * Converts a ProseMirror node tree into plain text.
47587
- */
47588
- static nodeToPlainText(node) {
47589
- if (!node) {
47590
- return '';
47591
- }
47592
- switch (node.type) {
47593
- case 'text':
47594
- return node.text || '';
47595
- case 'hard_break':
47596
- return '\n';
47597
- case 'paragraph': {
47598
- const paragraph = Array.isArray(node.content)
47599
- ? node.content.map((child) => this.nodeToPlainText(child)).join('')
47600
- : '';
47601
- return `${paragraph}\n`;
47602
- }
47603
- case 'list_item': {
47604
- const item = Array.isArray(node.content)
47605
- ? node.content.map((child) => this.nodeToPlainText(child)).join('')
47606
- : '';
47607
- return `- ${item.trim()}\n`;
47608
- }
47609
- default:
47610
- return Array.isArray(node.content)
47611
- ? node.content.map((child) => this.nodeToPlainText(child)).join('')
47612
- : '';
47613
- }
47614
- }
47615
- /**
47616
- * Applies a ProseMirror mark to text.
47617
- * @param text - The text to wrap
47618
- * @param mark - The mark to apply
47619
- * @param theme - Theme for color resolution
47620
- * @returns Wrapped HTML string
47621
- */
47622
- static applyMark(text, mark, _theme) {
47623
- switch (mark.type) {
47624
- case 'bold':
47625
- case 'strong':
47626
- return `<strong>${text}</strong>`;
47627
- case 'italic':
47628
- case 'em':
47629
- return `<em>${text}</em>`;
47630
- case 'underline':
47631
- return `<u>${text}</u>`;
47632
- case 'strike':
47633
- return `<s>${text}</s>`;
47634
- case 'code':
47635
- return `<code>${text}</code>`;
47636
- case 'link':
47637
- const href = mark.attrs?.href || '#';
47638
- return `<a href="${this.escapeHtml(href)}">${text}</a>`;
47639
- default:
47640
- return text;
47641
- }
47642
- }
47643
- /**
47644
- * Escapes HTML special characters.
47645
- * @param str - String to escape
47646
- * @returns Escaped string
47647
- */
47648
- static escapeHtml(str) {
47649
- return str
47650
- .replace(/&/g, '&amp;')
47651
- .replace(/</g, '&lt;')
47652
- .replace(/>/g, '&gt;')
47653
- .replace(/"/g, '&quot;')
47654
- .replace(/'/g, '&#039;');
47655
- }
47656
- }
47657
-
47658
- function resolveUrl(url, baseUrl) {
47659
- // url is absolute already
47660
- if (url.match(/^[a-z]+:\/\//i)) {
47661
- return url;
47662
- }
47663
- // url is absolute already, without protocol
47664
- if (url.match(/^\/\//)) {
47665
- return window.location.protocol + url;
47666
- }
47667
- // dataURI, mailto:, tel:, etc.
47668
- if (url.match(/^[a-z]+:/i)) {
47669
- return url;
47670
- }
47671
- const doc = document.implementation.createHTMLDocument();
47672
- const base = doc.createElement('base');
47673
- const a = doc.createElement('a');
47674
- doc.head.appendChild(base);
47675
- doc.body.appendChild(a);
47676
- if (baseUrl) {
47677
- base.href = baseUrl;
47678
- }
47679
- a.href = url;
47680
- return a.href;
47681
- }
47682
- const uuid = (() => {
47683
- // generate uuid for className of pseudo elements.
47684
- // We should not use GUIDs, otherwise pseudo elements sometimes cannot be captured.
47685
- let counter = 0;
47686
- // ref: http://stackoverflow.com/a/6248722/2519373
47687
- const random = () =>
47688
- // eslint-disable-next-line no-bitwise
47689
- `0000${((Math.random() * 36 ** 4) << 0).toString(36)}`.slice(-4);
47690
- return () => {
47691
- counter += 1;
47692
- return `u${random()}${counter}`;
47693
- };
47694
- })();
47695
- function toArray(arrayLike) {
47696
- const arr = [];
47697
- for (let i = 0, l = arrayLike.length; i < l; i++) {
47698
- arr.push(arrayLike[i]);
47699
- }
47700
- return arr;
47701
- }
47702
- let styleProps = null;
47703
- function getStyleProperties(options = {}) {
47704
- if (styleProps) {
47705
- return styleProps;
47706
- }
47707
- if (options.includeStyleProperties) {
47708
- styleProps = options.includeStyleProperties;
47709
- return styleProps;
47710
- }
47711
- styleProps = toArray(window.getComputedStyle(document.documentElement));
47712
- return styleProps;
47713
- }
47714
- function px(node, styleProperty) {
47715
- const win = node.ownerDocument.defaultView || window;
47716
- const val = win.getComputedStyle(node).getPropertyValue(styleProperty);
47717
- return val ? parseFloat(val.replace('px', '')) : 0;
47718
- }
47719
- function getNodeWidth(node) {
47720
- const leftBorder = px(node, 'border-left-width');
47721
- const rightBorder = px(node, 'border-right-width');
47722
- return node.clientWidth + leftBorder + rightBorder;
47723
- }
47724
- function getNodeHeight(node) {
47725
- const topBorder = px(node, 'border-top-width');
47726
- const bottomBorder = px(node, 'border-bottom-width');
47727
- return node.clientHeight + topBorder + bottomBorder;
47728
- }
47729
- function getImageSize(targetNode, options = {}) {
47730
- const width = options.width || getNodeWidth(targetNode);
47731
- const height = options.height || getNodeHeight(targetNode);
47732
- return { width, height };
47733
- }
47734
- function getPixelRatio() {
47735
- let ratio;
47736
- let FINAL_PROCESS;
47737
- try {
47738
- FINAL_PROCESS = process;
47739
- }
47740
- catch (e) {
47741
- // pass
47742
- }
47743
- const val = FINAL_PROCESS && FINAL_PROCESS.env
47744
- ? FINAL_PROCESS.env.devicePixelRatio
47745
- : null;
47746
- if (val) {
47747
- ratio = parseInt(val, 10);
47748
- if (Number.isNaN(ratio)) {
47749
- ratio = 1;
47750
- }
47751
- }
47752
- return ratio || window.devicePixelRatio || 1;
47753
- }
47754
- // @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#maximum_canvas_size
47755
- const canvasDimensionLimit = 16384;
47756
- function checkCanvasDimensions(canvas) {
47757
- if (canvas.width > canvasDimensionLimit ||
47758
- canvas.height > canvasDimensionLimit) {
47759
- if (canvas.width > canvasDimensionLimit &&
47760
- canvas.height > canvasDimensionLimit) {
47761
- if (canvas.width > canvas.height) {
47762
- canvas.height *= canvasDimensionLimit / canvas.width;
47763
- canvas.width = canvasDimensionLimit;
47764
- }
47765
- else {
47766
- canvas.width *= canvasDimensionLimit / canvas.height;
47767
- canvas.height = canvasDimensionLimit;
47768
- }
47769
- }
47770
- else if (canvas.width > canvasDimensionLimit) {
47771
- canvas.height *= canvasDimensionLimit / canvas.width;
47772
- canvas.width = canvasDimensionLimit;
47773
- }
47774
- else {
47775
- canvas.width *= canvasDimensionLimit / canvas.height;
47776
- canvas.height = canvasDimensionLimit;
47777
- }
47778
- }
47779
- }
47780
- function createImage(url) {
47781
- return new Promise((resolve, reject) => {
47782
- const img = new Image();
47783
- img.onload = () => {
47784
- img.decode().then(() => {
47785
- requestAnimationFrame(() => resolve(img));
47786
- });
47787
- };
47788
- img.onerror = reject;
47789
- img.crossOrigin = 'anonymous';
47790
- img.decoding = 'async';
47791
- img.src = url;
47792
- });
47793
- }
47794
- async function svgToDataURL(svg) {
47795
- return Promise.resolve()
47796
- .then(() => new XMLSerializer().serializeToString(svg))
47797
- .then(encodeURIComponent)
47798
- .then((html) => `data:image/svg+xml;charset=utf-8,${html}`);
47799
- }
47800
- async function nodeToDataURL(node, width, height) {
47801
- const xmlns = 'http://www.w3.org/2000/svg';
47802
- const svg = document.createElementNS(xmlns, 'svg');
47803
- const foreignObject = document.createElementNS(xmlns, 'foreignObject');
47804
- svg.setAttribute('width', `${width}`);
47805
- svg.setAttribute('height', `${height}`);
47806
- svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
47807
- foreignObject.setAttribute('width', '100%');
47808
- foreignObject.setAttribute('height', '100%');
47809
- foreignObject.setAttribute('x', '0');
47810
- foreignObject.setAttribute('y', '0');
47811
- foreignObject.setAttribute('externalResourcesRequired', 'true');
47812
- svg.appendChild(foreignObject);
47813
- foreignObject.appendChild(node);
47814
- return svgToDataURL(svg);
47815
- }
47816
- const isInstanceOfElement = (node, instance) => {
47817
- if (node instanceof instance)
47818
- return true;
47819
- const nodePrototype = Object.getPrototypeOf(node);
47820
- if (nodePrototype === null)
47821
- return false;
47822
- return (nodePrototype.constructor.name === instance.name ||
47823
- isInstanceOfElement(nodePrototype, instance));
47824
- };
47825
-
47826
- function formatCSSText(style) {
47827
- const content = style.getPropertyValue('content');
47828
- return `${style.cssText} content: '${content.replace(/'|"/g, '')}';`;
47829
- }
47830
- function formatCSSProperties(style, options) {
47831
- return getStyleProperties(options)
47832
- .map((name) => {
47833
- const value = style.getPropertyValue(name);
47834
- const priority = style.getPropertyPriority(name);
47835
- return `${name}: ${value}${priority ? ' !important' : ''};`;
47836
- })
47837
- .join(' ');
47838
- }
47839
- function getPseudoElementStyle(className, pseudo, style, options) {
47840
- const selector = `.${className}:${pseudo}`;
47841
- const cssText = style.cssText
47842
- ? formatCSSText(style)
47843
- : formatCSSProperties(style, options);
47844
- return document.createTextNode(`${selector}{${cssText}}`);
47845
- }
47846
- function clonePseudoElement(nativeNode, clonedNode, pseudo, options) {
47847
- const style = window.getComputedStyle(nativeNode, pseudo);
47848
- const content = style.getPropertyValue('content');
47849
- if (content === '' || content === 'none') {
47850
- return;
47851
- }
47852
- const className = uuid();
47853
- try {
47854
- clonedNode.className = `${clonedNode.className} ${className}`;
47855
- }
47856
- catch (err) {
47857
- return;
47858
- }
47859
- const styleElement = document.createElement('style');
47860
- styleElement.appendChild(getPseudoElementStyle(className, pseudo, style, options));
47861
- clonedNode.appendChild(styleElement);
47862
- }
47863
- function clonePseudoElements(nativeNode, clonedNode, options) {
47864
- clonePseudoElement(nativeNode, clonedNode, ':before', options);
47865
- clonePseudoElement(nativeNode, clonedNode, ':after', options);
47866
- }
47867
-
47868
- const WOFF = 'application/font-woff';
47869
- const JPEG = 'image/jpeg';
47870
- const mimes = {
47871
- woff: WOFF,
47872
- woff2: WOFF,
47873
- ttf: 'application/font-truetype',
47874
- eot: 'application/vnd.ms-fontobject',
47875
- png: 'image/png',
47876
- jpg: JPEG,
47877
- jpeg: JPEG,
47878
- gif: 'image/gif',
47879
- tiff: 'image/tiff',
47880
- svg: 'image/svg+xml',
47881
- webp: 'image/webp',
47882
- };
47883
- function getExtension(url) {
47884
- const match = /\.([^./]*?)$/g.exec(url);
47885
- return match ? match[1] : '';
47886
- }
47887
- function getMimeType(url) {
47888
- const extension = getExtension(url).toLowerCase();
47889
- return mimes[extension] || '';
47890
- }
47891
-
47892
- function getContentFromDataUrl(dataURL) {
47893
- return dataURL.split(/,/)[1];
47894
- }
47895
- function isDataUrl(url) {
47896
- return url.search(/^(data:)/) !== -1;
47897
- }
47898
- function makeDataUrl(content, mimeType) {
47899
- return `data:${mimeType};base64,${content}`;
47900
- }
47901
- async function fetchAsDataURL(url, init, process) {
47902
- const res = await fetch(url, init);
47903
- if (res.status === 404) {
47904
- throw new Error(`Resource "${res.url}" not found`);
47905
- }
47906
- const blob = await res.blob();
47907
- return new Promise((resolve, reject) => {
47908
- const reader = new FileReader();
47909
- reader.onerror = reject;
47910
- reader.onloadend = () => {
47911
- try {
47912
- resolve(process({ res, result: reader.result }));
47913
- }
47914
- catch (error) {
47915
- reject(error);
47916
- }
47917
- };
47918
- reader.readAsDataURL(blob);
47919
- });
47920
- }
47921
- const cache = {};
47922
- function getCacheKey(url, contentType, includeQueryParams) {
47923
- let key = url.replace(/\?.*/, '');
47924
- if (includeQueryParams) {
47925
- key = url;
47926
- }
47927
- // font resource
47928
- if (/ttf|otf|eot|woff2?/i.test(key)) {
47929
- key = key.replace(/.*\//, '');
47930
- }
47931
- return contentType ? `[${contentType}]${key}` : key;
47932
- }
47933
- async function resourceToDataURL(resourceUrl, contentType, options) {
47934
- const cacheKey = getCacheKey(resourceUrl, contentType, options.includeQueryParams);
47935
- if (cache[cacheKey] != null) {
47936
- return cache[cacheKey];
47937
- }
47938
- // ref: https://developer.mozilla.org/en/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest#Bypassing_the_cache
47939
- if (options.cacheBust) {
47940
- // eslint-disable-next-line no-param-reassign
47941
- resourceUrl += (/\?/.test(resourceUrl) ? '&' : '?') + new Date().getTime();
47942
- }
47943
- let dataURL;
47944
- try {
47945
- const content = await fetchAsDataURL(resourceUrl, options.fetchRequestInit, ({ res, result }) => {
47946
- if (!contentType) {
47947
- // eslint-disable-next-line no-param-reassign
47948
- contentType = res.headers.get('Content-Type') || '';
47949
- }
47950
- return getContentFromDataUrl(result);
47951
- });
47952
- dataURL = makeDataUrl(content, contentType);
47953
- }
47954
- catch (error) {
47955
- dataURL = options.imagePlaceholder || '';
47956
- let msg = `Failed to fetch resource: ${resourceUrl}`;
47957
- if (error) {
47958
- msg = typeof error === 'string' ? error : error.message;
47959
- }
47960
- if (msg) {
47961
- console.warn(msg);
47962
- }
47963
- }
47964
- cache[cacheKey] = dataURL;
47965
- return dataURL;
47966
- }
47967
-
47968
- async function cloneCanvasElement(canvas) {
47969
- const dataURL = canvas.toDataURL();
47970
- if (dataURL === 'data:,') {
47971
- return canvas.cloneNode(false);
47972
- }
47973
- return createImage(dataURL);
47974
- }
47975
- async function cloneVideoElement(video, options) {
47976
- if (video.currentSrc) {
47977
- const canvas = document.createElement('canvas');
47978
- const ctx = canvas.getContext('2d');
47979
- canvas.width = video.clientWidth;
47980
- canvas.height = video.clientHeight;
47981
- ctx === null || ctx === void 0 ? void 0 : ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
47982
- const dataURL = canvas.toDataURL();
47983
- return createImage(dataURL);
47984
- }
47985
- const poster = video.poster;
47986
- const contentType = getMimeType(poster);
47987
- const dataURL = await resourceToDataURL(poster, contentType, options);
47988
- return createImage(dataURL);
47989
- }
47990
- async function cloneIFrameElement(iframe, options) {
47991
- var _a;
47992
- try {
47993
- if ((_a = iframe === null || iframe === void 0 ? void 0 : iframe.contentDocument) === null || _a === void 0 ? void 0 : _a.body) {
47994
- return (await cloneNode(iframe.contentDocument.body, options, true));
47328
+ cache[cacheKey] = dataURL;
47329
+ return dataURL;
47330
+ }
47331
+
47332
+ async function cloneCanvasElement(canvas) {
47333
+ const dataURL = canvas.toDataURL();
47334
+ if (dataURL === 'data:,') {
47335
+ return canvas.cloneNode(false);
47336
+ }
47337
+ return createImage(dataURL);
47338
+ }
47339
+ async function cloneVideoElement(video, options) {
47340
+ if (video.currentSrc) {
47341
+ const canvas = document.createElement('canvas');
47342
+ const ctx = canvas.getContext('2d');
47343
+ canvas.width = video.clientWidth;
47344
+ canvas.height = video.clientHeight;
47345
+ ctx === null || ctx === void 0 ? void 0 : ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
47346
+ const dataURL = canvas.toDataURL();
47347
+ return createImage(dataURL);
47348
+ }
47349
+ const poster = video.poster;
47350
+ const contentType = getMimeType(poster);
47351
+ const dataURL = await resourceToDataURL(poster, contentType, options);
47352
+ return createImage(dataURL);
47353
+ }
47354
+ async function cloneIFrameElement(iframe, options) {
47355
+ var _a;
47356
+ try {
47357
+ if ((_a = iframe === null || iframe === void 0 ? void 0 : iframe.contentDocument) === null || _a === void 0 ? void 0 : _a.body) {
47358
+ return (await cloneNode(iframe.contentDocument.body, options, true));
47995
47359
  }
47996
47360
  }
47997
47361
  catch (_b) {
@@ -48492,156 +47856,808 @@ async function embedWebFonts(clonedNode, options) {
48492
47856
  clonedNode.appendChild(styleNode);
48493
47857
  }
48494
47858
  }
48495
- }
48496
-
48497
- async function toSvg(node, options = {}) {
48498
- const { width, height } = getImageSize(node, options);
48499
- const clonedNode = (await cloneNode(node, options, true));
48500
- await embedWebFonts(clonedNode, options);
48501
- await embedImages(clonedNode, options);
48502
- applyStyle(clonedNode, options);
48503
- const datauri = await nodeToDataURL(clonedNode, width, height);
48504
- return datauri;
48505
- }
48506
- async function toCanvas(node, options = {}) {
48507
- const { width, height } = getImageSize(node, options);
48508
- const svg = await toSvg(node, options);
48509
- const img = await createImage(svg);
48510
- const canvas = document.createElement('canvas');
48511
- const context = canvas.getContext('2d');
48512
- const ratio = options.pixelRatio || getPixelRatio();
48513
- const canvasWidth = options.canvasWidth || width;
48514
- const canvasHeight = options.canvasHeight || height;
48515
- canvas.width = canvasWidth * ratio;
48516
- canvas.height = canvasHeight * ratio;
48517
- if (!options.skipAutoScale) {
48518
- checkCanvasDimensions(canvas);
47859
+ }
47860
+
47861
+ async function toSvg(node, options = {}) {
47862
+ const { width, height } = getImageSize(node, options);
47863
+ const clonedNode = (await cloneNode(node, options, true));
47864
+ await embedWebFonts(clonedNode, options);
47865
+ await embedImages(clonedNode, options);
47866
+ applyStyle(clonedNode, options);
47867
+ const datauri = await nodeToDataURL(clonedNode, width, height);
47868
+ return datauri;
47869
+ }
47870
+ async function toCanvas(node, options = {}) {
47871
+ const { width, height } = getImageSize(node, options);
47872
+ const svg = await toSvg(node, options);
47873
+ const img = await createImage(svg);
47874
+ const canvas = document.createElement('canvas');
47875
+ const context = canvas.getContext('2d');
47876
+ const ratio = options.pixelRatio || getPixelRatio();
47877
+ const canvasWidth = options.canvasWidth || width;
47878
+ const canvasHeight = options.canvasHeight || height;
47879
+ canvas.width = canvasWidth * ratio;
47880
+ canvas.height = canvasHeight * ratio;
47881
+ if (!options.skipAutoScale) {
47882
+ checkCanvasDimensions(canvas);
47883
+ }
47884
+ canvas.style.width = `${canvasWidth}`;
47885
+ canvas.style.height = `${canvasHeight}`;
47886
+ if (options.backgroundColor) {
47887
+ context.fillStyle = options.backgroundColor;
47888
+ context.fillRect(0, 0, canvas.width, canvas.height);
47889
+ }
47890
+ context.drawImage(img, 0, 0, canvas.width, canvas.height);
47891
+ return canvas;
47892
+ }
47893
+ async function toPng(node, options = {}) {
47894
+ const canvas = await toCanvas(node, options);
47895
+ return canvas.toDataURL();
47896
+ }
47897
+
47898
+ class KritzelWheelHelper {
47899
+ static shouldKeepWheelInCustomElement(ev, customElement) {
47900
+ // Keep Ctrl+wheel available for viewport zoom gestures.
47901
+ if (ev.ctrlKey || customElement.isSelected !== true) {
47902
+ return false;
47903
+ }
47904
+ const path = typeof ev.composedPath === 'function' ? ev.composedPath() : [];
47905
+ const scrollContainer = this.getScrollableContainerFromEventPath(path, customElement);
47906
+ // No scroll container found inside the custom element: let the wheel
47907
+ // event pass through so the viewport can continue handling it.
47908
+ if (!scrollContainer) {
47909
+ return false;
47910
+ }
47911
+ return this.canElementConsumeWheel(scrollContainer, ev.deltaX, ev.deltaY);
47912
+ }
47913
+ static getScrollableContainerFromEventPath(path, customElement) {
47914
+ const potentialRoots = [];
47915
+ if (customElement.element instanceof HTMLElement) {
47916
+ potentialRoots.push(customElement.element);
47917
+ }
47918
+ if (customElement.elementRef instanceof HTMLElement) {
47919
+ potentialRoots.push(customElement.elementRef);
47920
+ }
47921
+ const legacyElementRef = customElement.elmeentRef;
47922
+ if (legacyElementRef instanceof HTMLElement) {
47923
+ potentialRoots.push(legacyElementRef);
47924
+ }
47925
+ for (const root of potentialRoots) {
47926
+ const rootIndex = path.findIndex(entry => entry === root);
47927
+ if (rootIndex === -1) {
47928
+ continue;
47929
+ }
47930
+ for (const entry of path.slice(0, rootIndex + 1)) {
47931
+ if (!(entry instanceof HTMLElement)) {
47932
+ continue;
47933
+ }
47934
+ if (this.isScrollableElement(entry)) {
47935
+ return entry;
47936
+ }
47937
+ }
47938
+ }
47939
+ // Fallback for synthetic/custom wheel events where composedPath is absent
47940
+ // or does not include the custom-element subtree.
47941
+ for (const root of potentialRoots) {
47942
+ if (this.isScrollableElement(root)) {
47943
+ return root;
47944
+ }
47945
+ const firstChild = root.firstElementChild;
47946
+ if (firstChild instanceof HTMLElement && this.isScrollableElement(firstChild)) {
47947
+ return firstChild;
47948
+ }
47949
+ }
47950
+ return null;
47951
+ }
47952
+ static isScrollableElement(element) {
47953
+ const canScrollVertically = element.scrollHeight - element.clientHeight > 0;
47954
+ const canScrollHorizontally = element.scrollWidth - element.clientWidth > 0;
47955
+ if (!canScrollVertically && !canScrollHorizontally) {
47956
+ return false;
47957
+ }
47958
+ const computedStyle = window.getComputedStyle(element);
47959
+ const overflowX = computedStyle.overflowX;
47960
+ const overflowY = computedStyle.overflowY;
47961
+ const allowsVerticalScroll = overflowY !== 'visible' && overflowY !== 'clip';
47962
+ const allowsHorizontalScroll = overflowX !== 'visible' && overflowX !== 'clip';
47963
+ return (canScrollVertically && allowsVerticalScroll) || (canScrollHorizontally && allowsHorizontalScroll);
47964
+ }
47965
+ static canElementConsumeWheel(element, deltaX, deltaY) {
47966
+ const canConsumeVertical = this.canElementConsumeVerticalWheel(element, deltaY);
47967
+ const canConsumeHorizontal = this.canElementConsumeHorizontalWheel(element, deltaX);
47968
+ // Keep wheel events inside the custom element whenever at least one
47969
+ // requested axis can still be consumed by its scroll container.
47970
+ return canConsumeVertical || canConsumeHorizontal;
47971
+ }
47972
+ static canElementConsumeVerticalWheel(element, deltaY) {
47973
+ if (deltaY === 0) {
47974
+ return false;
47975
+ }
47976
+ const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
47977
+ if (maxScrollTop === 0) {
47978
+ return false;
47979
+ }
47980
+ const epsilon = 1;
47981
+ const scrollingDown = deltaY > 0;
47982
+ const scrollingUp = deltaY < 0;
47983
+ if (scrollingDown) {
47984
+ return element.scrollTop < maxScrollTop - epsilon;
47985
+ }
47986
+ if (scrollingUp) {
47987
+ return element.scrollTop > epsilon;
47988
+ }
47989
+ return false;
47990
+ }
47991
+ static canElementConsumeHorizontalWheel(element, deltaX) {
47992
+ if (deltaX === 0) {
47993
+ return false;
47994
+ }
47995
+ const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth);
47996
+ if (maxScrollLeft === 0) {
47997
+ return false;
47998
+ }
47999
+ const epsilon = 1;
48000
+ const scrollingRight = deltaX > 0;
48001
+ const scrollingLeft = deltaX < 0;
48002
+ if (scrollingRight) {
48003
+ return element.scrollLeft < maxScrollLeft - epsilon;
48004
+ }
48005
+ if (scrollingLeft) {
48006
+ return element.scrollLeft > epsilon;
48007
+ }
48008
+ return false;
48009
+ }
48010
+ }
48011
+
48012
+ /**
48013
+ * Helper class for exporting Kritzel objects to SVG format.
48014
+ * Generates clean, standards-compliant SVG markup from canvas objects.
48015
+ */
48016
+ class KritzelSvgExportHelper {
48017
+ /**
48018
+ * Calculates the combined bounding box that encompasses all objects.
48019
+ * Uses rotatedBoundingBox to account for object rotation.
48020
+ * @param objects - Array of objects to calculate bounds for
48021
+ * @returns Combined bounding box, or null if no objects provided
48022
+ */
48023
+ static calculateCombinedBounds(objects) {
48024
+ if (!objects || objects.length === 0) {
48025
+ return null;
48026
+ }
48027
+ let minX = Infinity;
48028
+ let minY = Infinity;
48029
+ let maxX = -Infinity;
48030
+ let maxY = -Infinity;
48031
+ for (const obj of objects) {
48032
+ const bbox = obj.rotatedBoundingBox;
48033
+ minX = Math.min(minX, bbox.x);
48034
+ minY = Math.min(minY, bbox.y);
48035
+ maxX = Math.max(maxX, bbox.x + bbox.width);
48036
+ maxY = Math.max(maxY, bbox.y + bbox.height);
48037
+ }
48038
+ return {
48039
+ x: minX,
48040
+ y: minY,
48041
+ z: 1,
48042
+ width: maxX - minX,
48043
+ height: maxY - minY,
48044
+ };
48045
+ }
48046
+ /**
48047
+ * Generates a complete SVG document string from the given objects.
48048
+ * @param objects - Objects to export
48049
+ * @param options - Export options
48050
+ * @returns SVG document string
48051
+ */
48052
+ static generateSvg(objects, options = {}) {
48053
+ const { theme, padding = 0, includeXmlDeclaration = true, rasterSafeForCanvas = false, imageDataUrls } = options;
48054
+ if (!objects || objects.length === 0) {
48055
+ return '';
48056
+ }
48057
+ // Flatten groups to get all objects including nested children
48058
+ const allObjects = this.flattenObjects(objects);
48059
+ const bounds = this.calculateCombinedBounds(allObjects);
48060
+ if (!bounds) {
48061
+ return '';
48062
+ }
48063
+ // Add padding to bounds
48064
+ const viewBoxX = bounds.x - padding;
48065
+ const viewBoxY = bounds.y - padding;
48066
+ const viewBoxWidth = bounds.width + padding * 2;
48067
+ const viewBoxHeight = bounds.height + padding * 2;
48068
+ // Collect all defs (markers, patterns, etc.)
48069
+ const defs = this.collectDefs(allObjects, theme);
48070
+ // Generate SVG elements for each object
48071
+ const elements = objects
48072
+ .sort((a, b) => a.zIndex - b.zIndex)
48073
+ .map(obj => this.objectToSvgElement(obj, theme, imageDataUrls, rasterSafeForCanvas))
48074
+ .filter(Boolean)
48075
+ .join('\n ');
48076
+ const xmlDecl = includeXmlDeclaration ? '<?xml version="1.0" encoding="UTF-8"?>\n' : '';
48077
+ const defsContent = defs.length > 0 ? `\n <defs>\n ${defs.join('\n ')}\n </defs>` : '';
48078
+ return `${xmlDecl}<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBoxX} ${viewBoxY} ${viewBoxWidth} ${viewBoxHeight}" width="${viewBoxWidth}" height="${viewBoxHeight}">${defsContent}
48079
+ ${elements}
48080
+ </svg>`;
48081
+ }
48082
+ /**
48083
+ * Flattens a list of objects, expanding groups into their children recursively.
48084
+ * @param objects - Objects to flatten
48085
+ * @returns Flat array of all objects including group children
48086
+ */
48087
+ static flattenObjects(objects) {
48088
+ const result = [];
48089
+ for (const obj of objects) {
48090
+ if (KritzelClassHelper.isInstanceOf(obj, 'KritzelGroup')) {
48091
+ result.push(...this.flattenObjects(obj.children));
48092
+ }
48093
+ else {
48094
+ result.push(obj);
48095
+ }
48096
+ }
48097
+ return result;
48098
+ }
48099
+ /**
48100
+ * Resolves every image referenced by the given objects (including those
48101
+ * nested inside groups) to an inlined `data:` URL, keyed by object id.
48102
+ *
48103
+ * This is required for export flows that rasterize the SVG (e.g. PNG
48104
+ * export): an SVG loaded into an `<img>` element will NOT load external
48105
+ * or `blob:` resources, so a `<image href="blob:...">` renders as a
48106
+ * broken-image placeholder. Embedding the bytes as a base64 `data:` URL
48107
+ * makes the image self-contained and survive rasterization.
48108
+ *
48109
+ * Resolution is best-effort: images that fail to resolve are simply
48110
+ * omitted from the map, and the SVG falls back to whatever `href` the
48111
+ * synchronous path would have produced.
48112
+ * @param objects - Objects to inline images for
48113
+ * @param assetResolver - Resolver used to fetch asset bytes; optional
48114
+ * @returns Map of image object id -> inlined `data:` URL
48115
+ */
48116
+ static async resolveImageDataUrls(objects, assetResolver) {
48117
+ const dataUrls = new Map();
48118
+ if (!objects || objects.length === 0) {
48119
+ return dataUrls;
48120
+ }
48121
+ const images = this.flattenObjects(objects).filter((obj) => KritzelClassHelper.isInstanceOf(obj, 'KritzelImage'));
48122
+ await Promise.all(images.map(async (image) => {
48123
+ // Legacy inline data URL is already embeddable as-is.
48124
+ if (image.src?.startsWith('data:')) {
48125
+ dataUrls.set(image.id, image.src);
48126
+ return;
48127
+ }
48128
+ // A resolvedSrc that is itself a data URL can be embedded directly.
48129
+ if (image.resolvedSrc?.startsWith('data:')) {
48130
+ dataUrls.set(image.id, image.resolvedSrc);
48131
+ return;
48132
+ }
48133
+ if (!image.assetId || !assetResolver) {
48134
+ return;
48135
+ }
48136
+ try {
48137
+ const blob = await assetResolver.fetchBlob(image.assetId);
48138
+ dataUrls.set(image.id, await this.blobToDataUrl(blob));
48139
+ }
48140
+ catch (err) {
48141
+ console.warn(`[KritzelSvgExportHelper] Failed to inline image asset ${image.assetId} for export:`, err);
48142
+ }
48143
+ }));
48144
+ return dataUrls;
48145
+ }
48146
+ /**
48147
+ * Reads a Blob into a base64 `data:` URL.
48148
+ * @param blob - The blob to encode
48149
+ * @returns Promise resolving to the data URL string
48150
+ */
48151
+ static async blobToDataUrl(blob) {
48152
+ // Prefer FileReader in browser-like runtimes for efficiency.
48153
+ const hasFileReader = typeof FileReader !== 'undefined';
48154
+ if (hasFileReader) {
48155
+ try {
48156
+ return await new Promise((resolve, reject) => {
48157
+ const reader = new FileReader();
48158
+ reader.onload = () => {
48159
+ if (typeof reader.result === 'string') {
48160
+ resolve(reader.result);
48161
+ return;
48162
+ }
48163
+ reject(new Error('FileReader result is not a string'));
48164
+ };
48165
+ reader.onerror = () => reject(reader.error ?? new Error('Failed to read blob as data URL'));
48166
+ reader.readAsDataURL(blob);
48167
+ });
48168
+ }
48169
+ catch {
48170
+ // Fall through to the ArrayBuffer-based encoder.
48171
+ }
48172
+ }
48173
+ const bytes = new Uint8Array(await blob.arrayBuffer());
48174
+ const mimeType = blob.type || 'application/octet-stream';
48175
+ return `data:${mimeType};base64,${this.uint8ToBase64(bytes)}`;
48176
+ }
48177
+ /**
48178
+ * Encodes a byte array to base64 across browser and Node-like runtimes.
48179
+ */
48180
+ static uint8ToBase64(bytes) {
48181
+ if (typeof Buffer !== 'undefined') {
48182
+ return Buffer.from(bytes).toString('base64');
48183
+ }
48184
+ let binary = '';
48185
+ const chunkSize = 0x8000;
48186
+ for (let i = 0; i < bytes.length; i += chunkSize) {
48187
+ const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length));
48188
+ binary += String.fromCharCode(...chunk);
48189
+ }
48190
+ if (typeof btoa === 'function') {
48191
+ return btoa(binary);
48192
+ }
48193
+ throw new Error('No base64 encoder available in this runtime');
48194
+ }
48195
+ /**
48196
+ * Collects SVG defs (markers, patterns, etc.) needed by the objects.
48197
+ * @param objects - Objects to collect defs for
48198
+ * @param theme - Theme for color resolution
48199
+ * @returns Array of def element strings
48200
+ */
48201
+ static collectDefs(objects, theme) {
48202
+ const defs = [];
48203
+ for (const obj of objects) {
48204
+ if (KritzelClassHelper.isInstanceOf(obj, 'KritzelLine')) {
48205
+ if (obj.hasStartArrow) {
48206
+ defs.push(this.generateArrowMarker(obj, 'start', theme));
48207
+ }
48208
+ if (obj.hasEndArrow) {
48209
+ defs.push(this.generateArrowMarker(obj, 'end', theme));
48210
+ }
48211
+ }
48212
+ }
48213
+ return defs;
48214
+ }
48215
+ /**
48216
+ * Generates an SVG marker element for line arrows.
48217
+ * @param line - The line object
48218
+ * @param end - Which end of the line ('start' or 'end')
48219
+ * @param theme - Theme for color resolution
48220
+ * @returns SVG marker element string
48221
+ */
48222
+ static generateArrowMarker(line, end, _theme) {
48223
+ const markerId = end === 'start' ? line.startMarkerId : line.endMarkerId;
48224
+ const size = line.getArrowSize(end);
48225
+ const fill = line.getArrowFill(end);
48226
+ const style = end === 'start' ? line.arrows?.start?.style : line.arrows?.end?.style;
48227
+ const path = line.getArrowPath(style);
48228
+ const orient = end === 'start' ? 'auto-start-reverse' : 'auto';
48229
+ const refX = 0;
48230
+ const refY = size / 2;
48231
+ return `<marker id="${markerId}" markerWidth="${size}" markerHeight="${size}" refX="${refX}" refY="${refY}" orient="${orient}" markerUnits="userSpaceOnUse">
48232
+ <path d="${path}" fill="${fill}" transform="scale(${size / 10})"/>
48233
+ </marker>`;
48234
+ }
48235
+ /**
48236
+ * Converts a Kritzel object to its SVG element representation.
48237
+ * @param object - The object to convert
48238
+ * @param theme - Theme for color resolution
48239
+ * @param imageDataUrls - Optional map of image id -> inlined data URL
48240
+ * @returns SVG element string, or empty string if object type is not supported
48241
+ */
48242
+ static objectToSvgElement(object, theme, imageDataUrls, rasterSafeForCanvas = false) {
48243
+ if (KritzelClassHelper.isInstanceOf(object, 'KritzelPath')) {
48244
+ return this.pathToSvg(object, theme);
48245
+ }
48246
+ if (KritzelClassHelper.isInstanceOf(object, 'KritzelLine')) {
48247
+ return this.lineToSvg(object, theme);
48248
+ }
48249
+ if (KritzelClassHelper.isInstanceOf(object, 'KritzelShape')) {
48250
+ return this.shapeToSvg(object, theme);
48251
+ }
48252
+ if (KritzelClassHelper.isInstanceOf(object, 'KritzelText')) {
48253
+ return this.textToSvg(object, theme, rasterSafeForCanvas);
48254
+ }
48255
+ if (KritzelClassHelper.isInstanceOf(object, 'KritzelImage')) {
48256
+ return this.imageToSvg(object, imageDataUrls);
48257
+ }
48258
+ if (KritzelClassHelper.isInstanceOf(object, 'KritzelCustomElement')) {
48259
+ return this.customElementToSvg(object, rasterSafeForCanvas);
48260
+ }
48261
+ if (KritzelClassHelper.isInstanceOf(object, 'KritzelGroup')) {
48262
+ return this.groupToSvg(object, theme, imageDataUrls, rasterSafeForCanvas);
48263
+ }
48264
+ return '';
48265
+ }
48266
+ /**
48267
+ * Converts a KritzelPath to SVG.
48268
+ * @param path - The path object
48269
+ * @param theme - Theme for color resolution
48270
+ * @returns SVG element string
48271
+ */
48272
+ static pathToSvg(path, theme) {
48273
+ const transform = this.buildTransform(path);
48274
+ const fill = KritzelColorHelper.resolveThemeColor(path.fill, theme);
48275
+ const stroke = KritzelColorHelper.resolveThemeColor(path.stroke, theme);
48276
+ const opacity = path.opacity !== 1 ? ` opacity="${path.opacity}"` : '';
48277
+ const scale = this.getObjectScale(path);
48278
+ // Path needs to be wrapped in a g with translation since path.d uses local coordinates
48279
+ return `<g transform="${transform}"${opacity}>
48280
+ <svg viewBox="${path.viewBox}" width="${path.totalWidth / scale}" height="${path.totalHeight / scale}" overflow="visible">
48281
+ <path d="${path.d}" fill="${fill || 'none'}" stroke="${stroke || 'none'}"${path.strokeWidth ? ` stroke-width="${path.strokeWidth}"` : ''}/>
48282
+ </svg>
48283
+ </g>`;
48284
+ }
48285
+ /**
48286
+ * Converts a KritzelLine to SVG.
48287
+ * @param line - The line object
48288
+ * @param theme - Theme for color resolution
48289
+ * @returns SVG element string
48290
+ */
48291
+ static lineToSvg(line, theme) {
48292
+ const transform = this.buildTransform(line);
48293
+ const stroke = KritzelColorHelper.resolveThemeColor(line.stroke, theme);
48294
+ const opacity = line.opacity !== 1 ? ` opacity="${line.opacity}"` : '';
48295
+ const scale = this.getObjectScale(line);
48296
+ const markerStart = line.hasStartArrow ? ` marker-start="url(#${line.startMarkerId})"` : '';
48297
+ const markerEnd = line.hasEndArrow ? ` marker-end="url(#${line.endMarkerId})"` : '';
48298
+ return `<g transform="${transform}"${opacity}>
48299
+ <svg viewBox="${line.viewBox}" width="${line.totalWidth / scale}" height="${line.totalHeight / scale}" overflow="visible">
48300
+ <path d="${line.d}" fill="none" stroke="${stroke}" stroke-width="${line.strokeWidth}" stroke-linecap="round"${markerStart}${markerEnd}/>
48301
+ </svg>
48302
+ </g>`;
48303
+ }
48304
+ /**
48305
+ * Converts a KritzelShape to SVG.
48306
+ * @param shape - The shape object
48307
+ * @param theme - Theme for color resolution
48308
+ * @returns SVG element string
48309
+ */
48310
+ static shapeToSvg(shape, theme) {
48311
+ const transform = this.buildTransform(shape);
48312
+ const fill = KritzelColorHelper.resolveThemeColor(shape.fillColor, theme);
48313
+ const stroke = KritzelColorHelper.resolveThemeColor(shape.strokeColor, theme);
48314
+ const opacity = shape.opacity !== 1 ? ` opacity="${shape.opacity}"` : '';
48315
+ const scale = this.getObjectScale(shape);
48316
+ // Get the SVG path for the shape
48317
+ const pathD = shape.getSvgPath();
48318
+ // Handle text content if present
48319
+ let textContent = '';
48320
+ if (shape.editor && !shape.editor.state.doc.textContent.trim()) ;
48321
+ else if (shape.content) {
48322
+ const textHtml = this.prosemirrorToHtml(shape.content, shape, theme);
48323
+ if (textHtml) {
48324
+ const fontColor = KritzelColorHelper.resolveThemeColor(shape.fontColor, theme);
48325
+ textContent = `
48326
+ <foreignObject x="${shape.x}" y="${shape.y}" width="${shape.width}" height="${shape.height}">
48327
+ <div xmlns="http://www.w3.org/1999/xhtml" style="width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; text-align: center; font-family: ${shape.fontFamily}; font-size: ${shape.fontSize}px; color: ${fontColor}; overflow: hidden;">
48328
+ ${textHtml}
48329
+ </div>
48330
+ </foreignObject>`;
48331
+ }
48332
+ }
48333
+ return `<g transform="${transform}"${opacity}>
48334
+ <svg viewBox="${shape.viewBox}" width="${shape.totalWidth / scale}" height="${shape.totalHeight / scale}" overflow="visible" preserveAspectRatio="none">
48335
+ <path d="${pathD}" fill="${fill || 'transparent'}" stroke="${stroke}" stroke-width="${shape.strokeWidth}"/>${textContent}
48336
+ </svg>
48337
+ </g>`;
48338
+ }
48339
+ /**
48340
+ * Converts a KritzelText to SVG using foreignObject for rich text support.
48341
+ * @param text - The text object
48342
+ * @param theme - Theme for color resolution
48343
+ * @returns SVG element string
48344
+ */
48345
+ static textToSvg(text, theme, rasterSafeForCanvas = false) {
48346
+ if (rasterSafeForCanvas) {
48347
+ return this.textToSvgRasterSafe(text, theme);
48348
+ }
48349
+ const transform = this.buildTransform(text);
48350
+ const opacity = text.opacity !== 1 ? ` opacity="${text.opacity}"` : '';
48351
+ const fontColor = KritzelColorHelper.resolveThemeColor(text.fontColor, theme);
48352
+ const bgColor = KritzelColorHelper.resolveThemeColor(text.backgroundColor, theme);
48353
+ const scale = this.getObjectScale(text);
48354
+ // Convert ProseMirror content to HTML
48355
+ const htmlContent = this.prosemirrorToHtml(text.content, text, theme);
48356
+ // Calculate dimensions in SVG world units (world units = CSS px in a 1:1 SVG).
48357
+ const width = text.totalWidth / scale;
48358
+ const height = text.totalHeight / scale;
48359
+ // Convert the font size from pt to SVG world units (px equivalent).
48360
+ // text.fontSize is stored in CSS pt; 1 pt = 4/3 px at 96 dpi.
48361
+ // We also bake in scaleFactor (user resize) and divide by the object scale so
48362
+ // that the rendered font aligns with the world-unit dimensions above.
48363
+ // The CSS transform: scale(scaleFactor) is removed from the div because the
48364
+ // adjusted font-size already accounts for it – keeping both would double-scale
48365
+ // the content and overflow the foreignObject.
48366
+ const svgFontSizePx = (text.fontSize * (4 / 3) * (text.scaleFactor || 1) / scale).toFixed(4);
48367
+ return `<g transform="${transform}"${opacity}>
48368
+ <foreignObject x="0" y="0" width="${width}" height="${height}" overflow="visible">
48369
+ <div xmlns="http://www.w3.org/1999/xhtml" style="font-family: ${text.fontFamily}; font-size: ${svgFontSizePx}px; color: ${fontColor}; background-color: ${bgColor || 'transparent'}; white-space: pre-wrap; word-wrap: break-word; width: 100%; margin: 0; padding: 0; box-sizing: border-box;">${htmlContent.trim()}</div>
48370
+ </foreignObject>
48371
+ </g>`;
48372
+ }
48373
+ /**
48374
+ * Converts a KritzelText to SVG using pure SVG text primitives.
48375
+ * This path avoids `<foreignObject>`, which taints canvases when the
48376
+ * generated SVG is rasterized through `<img>` and `drawImage`.
48377
+ */
48378
+ static textToSvgRasterSafe(text, theme) {
48379
+ const transform = this.buildTransform(text);
48380
+ const opacity = text.opacity !== 1 ? ` opacity="${text.opacity}"` : '';
48381
+ const fontColor = KritzelColorHelper.resolveThemeColor(text.fontColor, theme);
48382
+ const bgColor = KritzelColorHelper.resolveThemeColor(text.backgroundColor, theme);
48383
+ const scale = this.getObjectScale(text);
48384
+ const width = text.totalWidth / scale;
48385
+ const height = text.totalHeight / scale;
48386
+ const lines = this.prosemirrorToPlainTextLines(text.content);
48387
+ const resolvedLines = lines.length > 0 ? lines : [''];
48388
+ // Convert the font size from pt to SVG world units (px equivalent).
48389
+ // text.fontSize is stored in CSS pt; 1 pt = 4/3 px at 96 dpi.
48390
+ // We also bake in scaleFactor (user resize) and divide by the object scale so
48391
+ // that the rendered lines align with the world-unit bounding box dimensions above.
48392
+ const resolvedFontSizePx = Math.max(1, text.fontSize * (4 / 3) * (text.scaleFactor || 1) / scale);
48393
+ const lineHeight = resolvedFontSizePx * 1.2;
48394
+ const startY = resolvedFontSizePx;
48395
+ const bgRect = bgColor && bgColor !== 'transparent'
48396
+ ? `<rect x="0" y="0" width="${width}" height="${height}" fill="${bgColor}"/>`
48397
+ : '';
48398
+ const tspans = resolvedLines
48399
+ .map((line, index) => {
48400
+ const y = startY + index * lineHeight;
48401
+ return `<tspan x="0" y="${y}" xml:space="preserve">${this.escapeHtml(line)}</tspan>`;
48402
+ })
48403
+ .join('');
48404
+ return `<g transform="${transform}"${opacity}>
48405
+ ${bgRect}
48406
+ <text font-family="${this.escapeHtml(text.fontFamily)}" font-size="${resolvedFontSizePx}" fill="${fontColor}">${tspans}</text>
48407
+ </g>`;
48519
48408
  }
48520
- canvas.style.width = `${canvasWidth}`;
48521
- canvas.style.height = `${canvasHeight}`;
48522
- if (options.backgroundColor) {
48523
- context.fillStyle = options.backgroundColor;
48524
- context.fillRect(0, 0, canvas.width, canvas.height);
48409
+ /**
48410
+ * Converts a KritzelImage to SVG.
48411
+ *
48412
+ * Prefers an inlined `data:` URL from `imageDataUrls` (keyed by object
48413
+ * id) when provided, so the embedded bytes survive rasterization to
48414
+ * PNG. Falls back to `resolvedSrc` (populated by the renderer via the
48415
+ * asset resolver) and finally the legacy inline `src` field for
48416
+ * documents persisted before the asset layer existed.
48417
+ *
48418
+ * Note: a `blob:` `resolvedSrc` renders correctly on-canvas but becomes
48419
+ * a broken-image placeholder once the SVG is loaded into an `<img>` and
48420
+ * drawn to a canvas. Callers that rasterize the SVG must pass
48421
+ * `imageDataUrls` (see {@link KritzelSvgExportHelper.resolveImageDataUrls}).
48422
+ * @param image - The image object
48423
+ * @param imageDataUrls - Optional map of image id -> inlined data URL
48424
+ * @returns SVG element string
48425
+ */
48426
+ static imageToSvg(image, imageDataUrls) {
48427
+ const transform = this.buildTransform(image);
48428
+ const opacity = image.opacity !== 1 ? ` opacity="${image.opacity}"` : '';
48429
+ const scale = this.getObjectScale(image);
48430
+ // Calculate dimensions
48431
+ const width = image.totalWidth / scale;
48432
+ const height = image.totalHeight / scale;
48433
+ const href = imageDataUrls?.get(image.id) || image.resolvedSrc || image.src || '';
48434
+ return `<g transform="${transform}"${opacity}>
48435
+ <image href="${href}" x="0" y="0" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet"/>
48436
+ </g>`;
48525
48437
  }
48526
- context.drawImage(img, 0, 0, canvas.width, canvas.height);
48527
- return canvas;
48528
- }
48529
- async function toPng(node, options = {}) {
48530
- const canvas = await toCanvas(node, options);
48531
- return canvas.toDataURL();
48532
- }
48533
-
48534
- class KritzelWheelHelper {
48535
- static shouldKeepWheelInCustomElement(ev, customElement) {
48536
- // Keep Ctrl+wheel available for viewport zoom gestures.
48537
- if (ev.ctrlKey || customElement.isSelected !== true) {
48538
- return false;
48539
- }
48540
- const path = typeof ev.composedPath === 'function' ? ev.composedPath() : [];
48541
- const scrollContainer = this.getScrollableContainerFromEventPath(path, customElement);
48542
- // No scroll container found inside the custom element: let the wheel
48543
- // event pass through so the viewport can continue handling it.
48544
- if (!scrollContainer) {
48545
- return false;
48438
+ /**
48439
+ * Converts a KritzelCustomElement to SVG.
48440
+ * SVG mode: embeds HTML in foreignObject (best-effort).
48441
+ * PNG/raster mode: renders placeholder rect with warning.
48442
+ * @param obj - The custom element object
48443
+ * @param rasterSafeForCanvas - If true, render placeholder (for PNG export)
48444
+ * @returns SVG element string
48445
+ */
48446
+ static customElementToSvg(obj, rasterSafeForCanvas = false) {
48447
+ const transform = this.buildTransform(obj);
48448
+ const opacity = obj.opacity !== 1 ? ` opacity="${obj.opacity}"` : '';
48449
+ const scale = this.getObjectScale(obj);
48450
+ const width = obj.totalWidth / scale;
48451
+ const height = obj.totalHeight / scale;
48452
+ if (rasterSafeForCanvas) {
48453
+ // PNG export: render placeholder rect (foreignObject doesn't rasterize to PNG well)
48454
+ console.warn('[KritzelCustomElement SVG Export] Custom elements do not serialize well to PNG. Use SVG export or screenshot instead.');
48455
+ return `<g transform="${transform}"${opacity}>
48456
+ <rect x="0" y="0" width="${width}" height="${height}" fill="#f0f0f0" stroke="#999" stroke-width="1" />
48457
+ <text x="${width / 2}" y="${height / 2}" text-anchor="middle" dominant-baseline="middle" font-size="12" fill="#999">[Custom Element]</text>
48458
+ </g>`;
48546
48459
  }
48547
- return this.canElementConsumeWheel(scrollContainer, ev.deltaX, ev.deltaY);
48460
+ // SVG export: use foreignObject to embed HTML
48461
+ const html = obj.element?.outerHTML || '<div style="border:1px dashed #ccc">Empty</div>';
48462
+ return `<g transform="${transform}"${opacity}>
48463
+ <foreignObject x="0" y="0" width="${width}" height="${height}">
48464
+ <div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;overflow:hidden;">
48465
+ ${html}
48466
+ </div>
48467
+ </foreignObject>
48468
+ </g>`;
48548
48469
  }
48549
- static getScrollableContainerFromEventPath(path, customElement) {
48550
- const potentialRoots = [];
48551
- if (customElement.element instanceof HTMLElement) {
48552
- potentialRoots.push(customElement.element);
48553
- }
48554
- if (customElement.elementRef instanceof HTMLElement) {
48555
- potentialRoots.push(customElement.elementRef);
48556
- }
48557
- const legacyElementRef = customElement.elmeentRef;
48558
- if (legacyElementRef instanceof HTMLElement) {
48559
- potentialRoots.push(legacyElementRef);
48560
- }
48561
- for (const root of potentialRoots) {
48562
- const rootIndex = path.findIndex(entry => entry === root);
48563
- if (rootIndex === -1) {
48564
- continue;
48565
- }
48566
- for (const entry of path.slice(0, rootIndex + 1)) {
48567
- if (!(entry instanceof HTMLElement)) {
48568
- continue;
48569
- }
48570
- if (this.isScrollableElement(entry)) {
48571
- return entry;
48572
- }
48573
- }
48574
- }
48575
- // Fallback for synthetic/custom wheel events where composedPath is absent
48576
- // or does not include the custom-element subtree.
48577
- for (const root of potentialRoots) {
48578
- if (this.isScrollableElement(root)) {
48579
- return root;
48580
- }
48581
- const firstChild = root.firstElementChild;
48582
- if (firstChild instanceof HTMLElement && this.isScrollableElement(firstChild)) {
48583
- return firstChild;
48584
- }
48470
+ /**
48471
+ * Converts a KritzelGroup to SVG by recursively converting children.
48472
+ * @param group - The group object
48473
+ * @param theme - Theme for color resolution
48474
+ * @param imageDataUrls - Optional map of image id -> inlined data URL
48475
+ * @returns SVG element string
48476
+ */
48477
+ static groupToSvg(group, theme, imageDataUrls, rasterSafeForCanvas = false) {
48478
+ const children = group.children
48479
+ .sort((a, b) => a.zIndex - b.zIndex)
48480
+ .map(child => this.objectToSvgElement(child, theme, imageDataUrls, rasterSafeForCanvas))
48481
+ .filter(Boolean)
48482
+ .join('\n ');
48483
+ if (!children) {
48484
+ return '';
48585
48485
  }
48586
- return null;
48486
+ const opacity = group.opacity !== 1 ? ` opacity="${group.opacity}"` : '';
48487
+ return `<g${opacity}>
48488
+ ${children}
48489
+ </g>`;
48587
48490
  }
48588
- static isScrollableElement(element) {
48589
- const canScrollVertically = element.scrollHeight - element.clientHeight > 0;
48590
- const canScrollHorizontally = element.scrollWidth - element.clientWidth > 0;
48591
- if (!canScrollVertically && !canScrollHorizontally) {
48592
- return false;
48491
+ /**
48492
+ * Builds a transform attribute string for an object.
48493
+ * Combines translation and rotation.
48494
+ * @param object - The object to build transform for
48495
+ * @returns Transform attribute value string
48496
+ */
48497
+ static buildTransform(object) {
48498
+ const transforms = [];
48499
+ const scale = this.getObjectScale(object);
48500
+ // Translation
48501
+ transforms.push(`translate(${object.translateX}, ${object.translateY})`);
48502
+ // Rotation around center
48503
+ if (object.rotation !== 0) {
48504
+ const centerX = object.totalWidth / 2 / scale;
48505
+ const centerY = object.totalHeight / 2 / scale;
48506
+ const degrees = object.rotation * (180 / Math.PI);
48507
+ transforms.push(`rotate(${degrees}, ${centerX}, ${centerY})`);
48593
48508
  }
48594
- const computedStyle = window.getComputedStyle(element);
48595
- const overflowX = computedStyle.overflowX;
48596
- const overflowY = computedStyle.overflowY;
48597
- const allowsVerticalScroll = overflowY !== 'visible' && overflowY !== 'clip';
48598
- const allowsHorizontalScroll = overflowX !== 'visible' && overflowX !== 'clip';
48599
- return (canScrollVertically && allowsVerticalScroll) || (canScrollHorizontally && allowsHorizontalScroll);
48509
+ return transforms.join(' ');
48600
48510
  }
48601
- static canElementConsumeWheel(element, deltaX, deltaY) {
48602
- const canConsumeVertical = this.canElementConsumeVerticalWheel(element, deltaY);
48603
- const canConsumeHorizontal = this.canElementConsumeHorizontalWheel(element, deltaX);
48604
- // Keep wheel events inside the custom element whenever at least one
48605
- // requested axis can still be consumed by its scroll container.
48606
- return canConsumeVertical || canConsumeHorizontal;
48511
+ /**
48512
+ * Returns a safe non-zero scale for export geometry calculations.
48513
+ */
48514
+ static getObjectScale(object) {
48515
+ const scale = object.scale ?? 1;
48516
+ return scale === 0 ? 1 : scale;
48607
48517
  }
48608
- static canElementConsumeVerticalWheel(element, deltaY) {
48609
- if (deltaY === 0) {
48610
- return false;
48518
+ /**
48519
+ * Converts ProseMirror JSON content to HTML string.
48520
+ * @param content - ProseMirror document JSON
48521
+ * @param context - The text or shape object for styling context
48522
+ * @param theme - Theme for color resolution
48523
+ * @returns HTML string
48524
+ */
48525
+ static prosemirrorToHtml(content, _context, theme) {
48526
+ if (!content || !content.content) {
48527
+ return '';
48611
48528
  }
48612
- const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
48613
- if (maxScrollTop === 0) {
48614
- return false;
48529
+ return content.content
48530
+ .map((node) => this.nodeToHtml(node, theme))
48531
+ .join('');
48532
+ }
48533
+ /**
48534
+ * Converts a ProseMirror node to HTML.
48535
+ * @param node - ProseMirror node JSON
48536
+ * @param theme - Theme for color resolution
48537
+ * @returns HTML string
48538
+ */
48539
+ static nodeToHtml(node, theme) {
48540
+ if (!node)
48541
+ return '';
48542
+ switch (node.type) {
48543
+ case 'paragraph':
48544
+ const pContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
48545
+ return `<p style="margin: 0;">${pContent || '<br/>'}</p>`;
48546
+ case 'text':
48547
+ let text = this.escapeHtml(node.text || '');
48548
+ if (node.marks) {
48549
+ for (const mark of node.marks) {
48550
+ text = this.applyMark(text, mark, theme);
48551
+ }
48552
+ }
48553
+ return text;
48554
+ case 'hard_break':
48555
+ return '<br/>';
48556
+ case 'bullet_list':
48557
+ const blContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
48558
+ return `<ul style="margin: 0; padding-left: 1.5em;">${blContent}</ul>`;
48559
+ case 'ordered_list':
48560
+ const olContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
48561
+ return `<ol style="margin: 0; padding-left: 1.5em;">${olContent}</ol>`;
48562
+ case 'list_item':
48563
+ const liContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
48564
+ return `<li>${liContent}</li>`;
48565
+ default:
48566
+ // For unknown node types, try to render content
48567
+ if (node.content) {
48568
+ return node.content.map((n) => this.nodeToHtml(n, theme)).join('');
48569
+ }
48570
+ return '';
48615
48571
  }
48616
- const epsilon = 1;
48617
- const scrollingDown = deltaY > 0;
48618
- const scrollingUp = deltaY < 0;
48619
- if (scrollingDown) {
48620
- return element.scrollTop < maxScrollTop - epsilon;
48572
+ }
48573
+ /**
48574
+ * Converts ProseMirror JSON content to plain-text lines.
48575
+ * Used by raster-safe SVG export, which cannot depend on HTML/foreignObject.
48576
+ */
48577
+ static prosemirrorToPlainTextLines(content) {
48578
+ if (!content || !Array.isArray(content.content)) {
48579
+ return [];
48621
48580
  }
48622
- if (scrollingUp) {
48623
- return element.scrollTop > epsilon;
48581
+ const plainText = content.content
48582
+ .map((node) => this.nodeToPlainText(node))
48583
+ .join('')
48584
+ .replace(/\r\n/g, '\n')
48585
+ .replace(/\n+$/, '');
48586
+ if (!plainText) {
48587
+ return [];
48624
48588
  }
48625
- return false;
48589
+ return plainText.split('\n');
48626
48590
  }
48627
- static canElementConsumeHorizontalWheel(element, deltaX) {
48628
- if (deltaX === 0) {
48629
- return false;
48630
- }
48631
- const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth);
48632
- if (maxScrollLeft === 0) {
48633
- return false;
48591
+ /**
48592
+ * Converts a ProseMirror node tree into plain text.
48593
+ */
48594
+ static nodeToPlainText(node) {
48595
+ if (!node) {
48596
+ return '';
48634
48597
  }
48635
- const epsilon = 1;
48636
- const scrollingRight = deltaX > 0;
48637
- const scrollingLeft = deltaX < 0;
48638
- if (scrollingRight) {
48639
- return element.scrollLeft < maxScrollLeft - epsilon;
48598
+ switch (node.type) {
48599
+ case 'text':
48600
+ return node.text || '';
48601
+ case 'hard_break':
48602
+ return '\n';
48603
+ case 'paragraph': {
48604
+ const paragraph = Array.isArray(node.content)
48605
+ ? node.content.map((child) => this.nodeToPlainText(child)).join('')
48606
+ : '';
48607
+ return `${paragraph}\n`;
48608
+ }
48609
+ case 'list_item': {
48610
+ const item = Array.isArray(node.content)
48611
+ ? node.content.map((child) => this.nodeToPlainText(child)).join('')
48612
+ : '';
48613
+ return `- ${item.trim()}\n`;
48614
+ }
48615
+ default:
48616
+ return Array.isArray(node.content)
48617
+ ? node.content.map((child) => this.nodeToPlainText(child)).join('')
48618
+ : '';
48640
48619
  }
48641
- if (scrollingLeft) {
48642
- return element.scrollLeft > epsilon;
48620
+ }
48621
+ /**
48622
+ * Applies a ProseMirror mark to text.
48623
+ * @param text - The text to wrap
48624
+ * @param mark - The mark to apply
48625
+ * @param theme - Theme for color resolution
48626
+ * @returns Wrapped HTML string
48627
+ */
48628
+ static applyMark(text, mark, _theme) {
48629
+ switch (mark.type) {
48630
+ case 'bold':
48631
+ case 'strong':
48632
+ return `<strong>${text}</strong>`;
48633
+ case 'italic':
48634
+ case 'em':
48635
+ return `<em>${text}</em>`;
48636
+ case 'underline':
48637
+ return `<u>${text}</u>`;
48638
+ case 'strike':
48639
+ return `<s>${text}</s>`;
48640
+ case 'code':
48641
+ return `<code>${text}</code>`;
48642
+ case 'link':
48643
+ const href = mark.attrs?.href || '#';
48644
+ return `<a href="${this.escapeHtml(href)}">${text}</a>`;
48645
+ default:
48646
+ return text;
48643
48647
  }
48644
- return false;
48648
+ }
48649
+ /**
48650
+ * Escapes HTML special characters.
48651
+ * @param str - String to escape
48652
+ * @returns Escaped string
48653
+ */
48654
+ static escapeHtml(str) {
48655
+ return str
48656
+ .replace(/&/g, '&amp;')
48657
+ .replace(/</g, '&lt;')
48658
+ .replace(/>/g, '&gt;')
48659
+ .replace(/"/g, '&quot;')
48660
+ .replace(/'/g, '&#039;');
48645
48661
  }
48646
48662
  }
48647
48663
 
@@ -49838,10 +49854,7 @@ const KritzelEngine = /*@__PURE__*/ proxyCustomElement(class KritzelEngine exten
49838
49854
  console.error('Failed to export viewport as PNG: screenshot could not be generated');
49839
49855
  return;
49840
49856
  }
49841
- const link = document.createElement('a');
49842
- link.download = filename;
49843
- link.href = dataUrl;
49844
- link.click();
49857
+ this.triggerDataUrlDownload(dataUrl, filename);
49845
49858
  }
49846
49859
  catch (error) {
49847
49860
  console.error('Failed to export viewport as PNG:', error);
@@ -49863,129 +49876,57 @@ const KritzelEngine = /*@__PURE__*/ proxyCustomElement(class KritzelEngine exten
49863
49876
  console.error('Failed to export viewport as SVG: screenshot could not be generated');
49864
49877
  return;
49865
49878
  }
49866
- const link = document.createElement('a');
49867
- link.download = filename;
49868
- link.href = dataUrl;
49869
- link.click();
49879
+ this.triggerDataUrlDownload(dataUrl, filename);
49870
49880
  }
49871
49881
  catch (error) {
49872
49882
  console.error('Failed to export viewport as SVG:', error);
49873
49883
  }
49874
49884
  }
49875
49885
  /**
49876
- * Generates an SVG string from the currently selected objects.
49877
- * Creates clean, standards-compliant SVG markup by directly serializing object properties.
49878
- * @param options - Optional export settings (theme, padding, raster-safe mode for canvas export).
49879
- * @returns SVG markup string, or null if no objects are selected.
49886
+ * Returns export formats supported by the currently selected object.
49887
+ * Only returns formats when exactly one object is selected.
49880
49888
  */
49881
- async getSelectedObjectsAsSvgString(options) {
49882
- const selectedObjects = await this.getSelectedObjects();
49883
- if (!selectedObjects || selectedObjects.length === 0) {
49884
- return null;
49889
+ async getSelectedObjectSupportedExportFormats() {
49890
+ const selected = this.getSingleSelectedExportableObject();
49891
+ if (!selected) {
49892
+ return [];
49885
49893
  }
49886
- const theme = options?.theme ?? this.core.themeManager.getStoredTheme();
49887
- // Inline image bytes as data URLs so they survive both standalone SVG
49888
- // files and rasterization to PNG (a `blob:` href is not loaded when an
49889
- // SVG is drawn into a canvas, which otherwise yields a broken image).
49890
- const imageDataUrls = await KritzelSvgExportHelper.resolveImageDataUrls(selectedObjects, this.core.assetResolver);
49891
- return KritzelSvgExportHelper.generateSvg(selectedObjects, {
49892
- theme,
49893
- padding: options?.padding ?? 0,
49894
- includeXmlDeclaration: true,
49895
- rasterSafeForCanvas: options?.rasterSafeForCanvas ?? false,
49896
- imageDataUrls,
49897
- });
49894
+ return selected.getSupportedExportFormats();
49898
49895
  }
49899
49896
  /**
49900
- * Exports the currently selected objects as an SVG file and triggers a browser download.
49901
- * Creates clean, standards-compliant SVG markup suitable for import into vector editors.
49902
- * @param options - Optional export settings (theme, padding).
49897
+ * Checks whether the currently selected object supports a specific export format.
49898
+ * Returns false when none or multiple objects are selected.
49903
49899
  */
49904
- async exportSelectedObjectsAsSvg(options) {
49905
- try {
49906
- const svgString = await this.getSelectedObjectsAsSvgString(options);
49907
- if (!svgString) {
49908
- console.warn('No objects selected for SVG export');
49909
- return;
49910
- }
49911
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
49912
- const filename = `selection-${timestamp}.svg`;
49913
- const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
49914
- const url = URL.createObjectURL(blob);
49915
- const link = document.createElement('a');
49916
- link.download = filename;
49917
- link.href = url;
49918
- link.click();
49919
- URL.revokeObjectURL(url);
49920
- }
49921
- catch (error) {
49922
- console.error('Failed to export selected objects as SVG:', error);
49900
+ async canExportSelectedObjectAs(format) {
49901
+ const selected = this.getSingleSelectedExportableObject();
49902
+ if (!selected) {
49903
+ return false;
49923
49904
  }
49905
+ return selected.getSupportedExportFormats().includes(format);
49924
49906
  }
49925
49907
  /**
49926
- * Generates a PNG data URL from the currently selected objects.
49927
- * Converts the SVG representation to a rasterized PNG image.
49928
- * @param options - Optional export settings (theme, padding, scale for resolution).
49929
- * @returns PNG data URL string, or null if no objects are selected.
49908
+ * Exports the currently selected object in the requested format and triggers a file download.
49909
+ * Only supports single selection.
49930
49910
  */
49931
- async getSelectedObjectsAsPngDataUrl(options) {
49932
- const svgString = await this.getSelectedObjectsAsSvgString({
49933
- theme: options?.theme,
49934
- padding: options?.padding,
49935
- // Avoid foreignObject-based text serialization, which taints the
49936
- // canvas in SVG->PNG conversion paths.
49937
- rasterSafeForCanvas: true,
49938
- });
49939
- if (!svgString) {
49940
- return null;
49911
+ async exportSelectedObject(format) {
49912
+ const selected = this.getSingleSelectedExportableObject();
49913
+ if (!selected) {
49914
+ return;
49941
49915
  }
49942
- const scale = options?.scale ?? 2; // Default 2x for better quality
49943
- return new Promise((resolve, reject) => {
49944
- const img = new Image();
49945
- const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
49946
- const url = URL.createObjectURL(svgBlob);
49947
- img.onload = () => {
49948
- const canvas = document.createElement('canvas');
49949
- canvas.width = img.width * scale;
49950
- canvas.height = img.height * scale;
49951
- const ctx = canvas.getContext('2d');
49952
- if (!ctx) {
49953
- URL.revokeObjectURL(url);
49954
- reject(new Error('Failed to get canvas context'));
49955
- return;
49956
- }
49957
- ctx.scale(scale, scale);
49958
- ctx.drawImage(img, 0, 0);
49959
- URL.revokeObjectURL(url);
49960
- resolve(canvas.toDataURL('image/png'));
49961
- };
49962
- img.onerror = () => {
49963
- URL.revokeObjectURL(url);
49964
- reject(new Error('Failed to load SVG for PNG conversion'));
49965
- };
49966
- img.src = url;
49967
- });
49968
- }
49969
- /**
49970
- * Exports the currently selected objects as a PNG file and triggers a browser download.
49971
- * @param options - Optional export settings (theme, padding, scale for resolution).
49972
- */
49973
- async exportSelectedObjectsAsPng(options) {
49974
49916
  try {
49975
- const dataUrl = await this.getSelectedObjectsAsPngDataUrl(options);
49917
+ const dataUrl = await selected.exportAs(format, this.getObjectExportContext());
49976
49918
  if (!dataUrl) {
49977
- console.warn('No objects selected for PNG export');
49919
+ console.error(`Failed to export selected object as ${format}: export returned no data URL`);
49978
49920
  return;
49979
49921
  }
49922
+ const activeWorkspaceName = this.core.store.state?.activeWorkspace?.name || 'workspace';
49923
+ const objectName = selected.__class__.replace(/^Kritzel/, '').toLowerCase() || 'object';
49980
49924
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
49981
- const filename = `selection-${timestamp}.png`;
49982
- const link = document.createElement('a');
49983
- link.download = filename;
49984
- link.href = dataUrl;
49985
- link.click();
49925
+ const filename = `${activeWorkspaceName}-${objectName}-${timestamp}.${format}`;
49926
+ this.triggerDataUrlDownload(dataUrl, filename);
49986
49927
  }
49987
49928
  catch (error) {
49988
- console.error('Failed to export selected objects as PNG:', error);
49929
+ console.error(`Failed to export selected object as ${format}:`, error);
49989
49930
  }
49990
49931
  }
49991
49932
  /**
@@ -50180,6 +50121,73 @@ const KritzelEngine = /*@__PURE__*/ proxyCustomElement(class KritzelEngine exten
50180
50121
  const response = await fetch(dataUrl);
50181
50122
  return response.blob();
50182
50123
  }
50124
+ triggerDataUrlDownload(dataUrl, filename) {
50125
+ const link = document.createElement('a');
50126
+ link.download = filename;
50127
+ link.href = dataUrl;
50128
+ link.click();
50129
+ }
50130
+ getSingleSelectedExportableObject() {
50131
+ const selectedObjects = this.core.store.selectionGroup?.objects ?? [];
50132
+ if (selectedObjects.length !== 1) {
50133
+ return null;
50134
+ }
50135
+ const [selected] = selectedObjects;
50136
+ if (!ObjectHelper.isKritzelExportable(selected)) {
50137
+ return null;
50138
+ }
50139
+ return selected;
50140
+ }
50141
+ getObjectExportContext() {
50142
+ return {
50143
+ getObjectAsSvgDataUrl: async (object, options) => {
50144
+ const svg = await this.getObjectAsSvgMarkup(object, options?.rasterSafeForCanvas ?? false);
50145
+ return svg ? this.svgMarkupToDataUrl(svg) : null;
50146
+ },
50147
+ getObjectAsPngDataUrl: async (object) => {
50148
+ const svg = await this.getObjectAsSvgMarkup(object, true);
50149
+ return svg ? this.svgMarkupToPngDataUrl(svg) : null;
50150
+ },
50151
+ };
50152
+ }
50153
+ async getObjectAsSvgMarkup(object, rasterSafeForCanvas) {
50154
+ const imageDataUrls = await KritzelSvgExportHelper.resolveImageDataUrls([object], this.core.assetResolver);
50155
+ const svg = KritzelSvgExportHelper.generateSvg([object], {
50156
+ theme: this.theme,
50157
+ rasterSafeForCanvas,
50158
+ imageDataUrls,
50159
+ });
50160
+ return svg || null;
50161
+ }
50162
+ svgMarkupToDataUrl(svgMarkup) {
50163
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgMarkup)}`;
50164
+ }
50165
+ async svgMarkupToPngDataUrl(svgMarkup) {
50166
+ const svgBlob = new Blob([svgMarkup], { type: 'image/svg+xml;charset=utf-8' });
50167
+ const svgBlobUrl = URL.createObjectURL(svgBlob);
50168
+ try {
50169
+ const img = await new Promise((resolve, reject) => {
50170
+ const element = new Image();
50171
+ element.onload = () => resolve(element);
50172
+ element.onerror = err => reject(err);
50173
+ element.src = svgBlobUrl;
50174
+ });
50175
+ const width = Math.max(1, Math.ceil(img.naturalWidth || img.width));
50176
+ const height = Math.max(1, Math.ceil(img.naturalHeight || img.height));
50177
+ const canvas = document.createElement('canvas');
50178
+ canvas.width = width;
50179
+ canvas.height = height;
50180
+ const context = canvas.getContext('2d');
50181
+ if (!context) {
50182
+ throw new Error('Could not create 2D canvas context for PNG export');
50183
+ }
50184
+ context.drawImage(img, 0, 0, width, height);
50185
+ return canvas.toDataURL('image/png');
50186
+ }
50187
+ finally {
50188
+ URL.revokeObjectURL(svgBlobUrl);
50189
+ }
50190
+ }
50183
50191
  getImportedWorkspaceName(rawName) {
50184
50192
  if (typeof rawName === 'string' && rawName.trim().length > 0) {
50185
50193
  return rawName;
@@ -51060,7 +51068,7 @@ const KritzelEngine = /*@__PURE__*/ proxyCustomElement(class KritzelEngine exten
51060
51068
  this.emitObjectsInViewportChange();
51061
51069
  }
51062
51070
  }
51063
- return (h(Host, null, h("kritzel-loading-overlay", { visible: this.core.store.state.isLoading, text: this.core.localizationManager.translate('engine.loading') }), h("kritzel-sync-indicator", { visible: this.isSyncing, text: "Syncing..." }), this.core.store.state.debugInfo.showViewportInfo && (h("div", { class: "debug-panel", style: {
51071
+ return (h(Host, null, this.core.store.state.debugInfo.showViewportInfo && (h("div", { class: "debug-panel", style: {
51064
51072
  color: 'var(--kritzel-global-text-primary)',
51065
51073
  } }, h("div", null, "ActiveWorkspaceId: ", this.core.store.state?.activeWorkspace?.id), h("div", null, "ActiveWorkspaceName: ", this.core.store.state?.activeWorkspace?.name), h("div", null, "TranslateX: ", this.core.store.state?.translateX), h("div", null, "TranslateY: ", this.core.store.state?.translateY), h("div", null, "ViewportWidth: ", this.core.store.state?.viewportWidth), h("div", null, "ViewportHeight: ", this.core.store.state?.viewportHeight), h("div", null, "PointerCount: ", this.core.store.state.pointers.size), h("div", null, "Scale: ", this.core.store.state?.scale), h("div", null, "ActiveTool: ", this.core.store.state?.activeTool?.name), h("div", null, "HasViewportChanged: ", this.core.store.state?.hasViewportChanged ? 'true' : 'false'), h("div", null, "IsEnabled: ", this.core.store.state?.isEnabled ? 'true' : 'false'), h("div", null, "IsScaling: ", this.core.store.state?.isScaling ? 'true' : 'false'), h("div", null, "IsPanning: ", this.core.store.state?.isPanning ? 'true' : 'false'), h("div", null, "IsSelecting: ", this.isSelecting ? 'true' : 'false'), h("div", null, "IsSelectionActive: ", this.isSelectionActive ? 'true' : 'false'), h("div", null, "IsResizeHandleSelected: ", this.core.store.state.isResizeHandleSelected ? 'true' : 'false'), h("div", null, "IsRotationHandleSelected: ", this.core.store.state.isRotationHandleSelected ? 'true' : 'false'), h("div", null, "IsRotationHandleHovered: ", this.core.store.state.isRotationHandleHovered ? 'true' : 'false'), h("div", null, "IsDrawing: ", this.core.store.state.isDrawing ? 'true' : 'false'), h("div", null, "IsWriting: ", this.core.store.state.isWriting ? 'true' : 'false'), h("div", null, "IsPointerDown: ", this.core.store.isPointerDown ? 'true' : 'false'), h("div", null, "PointerX: ", this.core.store.state?.pointerX), h("div", null, "PointerY: ", this.core.store.state?.pointerY), h("div", null, "TotalObjects: ", this.core.store.totalObjectCount), h("div", null, "ObjectsInViewport: ", this.core.store.objectsInViewport.length), h("div", null, "SelectedObjects: ", this.core.store.selectionGroup?.objects.length || 0), h("div", null, "ViewportCenter: (", viewportCenterX.toFixed(2), ", ", viewportCenterY.toFixed(2), ")"))), h("div", { id: "origin", class: "origin", style: {
51066
51074
  transform: `matrix(${this.core.store.state?.scale}, 0, 0, ${this.core.store.state?.scale}, ${this.core.store.state?.translateX}, ${this.core.store.state?.translateY})`,
@@ -51659,10 +51667,9 @@ const KritzelEngine = /*@__PURE__*/ proxyCustomElement(class KritzelEngine exten
51659
51667
  "getScreenshot": [64],
51660
51668
  "exportViewportAsPng": [64],
51661
51669
  "exportViewportAsSvg": [64],
51662
- "getSelectedObjectsAsSvgString": [64],
51663
- "exportSelectedObjectsAsSvg": [64],
51664
- "getSelectedObjectsAsPngDataUrl": [64],
51665
- "exportSelectedObjectsAsPng": [64],
51670
+ "getSelectedObjectSupportedExportFormats": [64],
51671
+ "canExportSelectedObjectAs": [64],
51672
+ "exportSelectedObject": [64],
51666
51673
  "exportAsJson": [64],
51667
51674
  "importFromJson": [64],
51668
51675
  "loadObjectsFromJson": [64],
@@ -51763,7 +51770,7 @@ function defineCustomElement() {
51763
51770
  if (typeof customElements === "undefined") {
51764
51771
  return;
51765
51772
  }
51766
- const components = ["kritzel-engine", "kritzel-awareness-cursors", "kritzel-cursor-trail", "kritzel-icon", "kritzel-loading-overlay", "kritzel-sync-indicator", "kritzel-watermark"];
51773
+ const components = ["kritzel-engine", "kritzel-awareness-cursors", "kritzel-cursor-trail", "kritzel-icon", "kritzel-watermark"];
51767
51774
  components.forEach(tagName => { switch (tagName) {
51768
51775
  case "kritzel-engine":
51769
51776
  if (!customElements.get(transformTag(tagName))) {
@@ -51771,26 +51778,16 @@ function defineCustomElement() {
51771
51778
  }
51772
51779
  break;
51773
51780
  case "kritzel-awareness-cursors":
51774
- if (!customElements.get(transformTag(tagName))) {
51775
- defineCustomElement$6();
51776
- }
51777
- break;
51778
- case "kritzel-cursor-trail":
51779
- if (!customElements.get(transformTag(tagName))) {
51780
- defineCustomElement$5();
51781
- }
51782
- break;
51783
- case "kritzel-icon":
51784
51781
  if (!customElements.get(transformTag(tagName))) {
51785
51782
  defineCustomElement$4();
51786
51783
  }
51787
51784
  break;
51788
- case "kritzel-loading-overlay":
51785
+ case "kritzel-cursor-trail":
51789
51786
  if (!customElements.get(transformTag(tagName))) {
51790
51787
  defineCustomElement$3();
51791
51788
  }
51792
51789
  break;
51793
- case "kritzel-sync-indicator":
51790
+ case "kritzel-icon":
51794
51791
  if (!customElements.get(transformTag(tagName))) {
51795
51792
  defineCustomElement$2();
51796
51793
  }