kritzel-stencil 0.4.7 → 0.4.9

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 (113) hide show
  1. package/dist/cjs/index.cjs.js +9 -1
  2. package/dist/cjs/kritzel-active-users_47.cjs.entry.js +1396 -1243
  3. package/dist/cjs/loader.cjs.js +1 -1
  4. package/dist/cjs/{schema.constants-C8hqt--r.js → schema.constants-ByNZ_qAj.js} +136 -7
  5. package/dist/cjs/stencil.cjs.js +1 -1
  6. package/dist/collection/classes/handlers/line-handle.handler.js +1 -1
  7. package/dist/collection/classes/managers/license.manager.js +56 -0
  8. package/dist/collection/classes/objects/image.class.js +9 -0
  9. package/dist/collection/classes/objects/line.class.js +1 -1
  10. package/dist/collection/classes/objects/path.class.js +9 -0
  11. package/dist/collection/classes/objects/shape.class.js +12 -3
  12. package/dist/collection/classes/objects/text.class.js +1 -1
  13. package/dist/collection/classes/providers/sync/broadcast-sync-provider.class.js +1 -0
  14. package/dist/collection/classes/providers/sync/hocuspocus-sync-provider.class.js +2 -0
  15. package/dist/collection/classes/providers/sync/in-memory-sync-provider.class.js +1 -0
  16. package/dist/collection/classes/providers/sync/indexeddb-sync-provider.class.js +1 -0
  17. package/dist/collection/classes/providers/sync/websocket-sync-provider.class.js +2 -0
  18. package/dist/collection/components/core/kritzel-editor/kritzel-editor.js +197 -67
  19. package/dist/collection/components/core/kritzel-engine/kritzel-engine.js +146 -172
  20. package/dist/collection/components/ui/kritzel-export/kritzel-export.js +1 -1
  21. package/dist/collection/components/ui/kritzel-loading-overlay/kritzel-loading-overlay.js +1 -1
  22. package/dist/collection/components/ui/kritzel-login-dialog/kritzel-login-dialog.js +1 -1
  23. package/dist/collection/components/ui/kritzel-more-menu/kritzel-more-menu.js +90 -4
  24. package/dist/collection/components/ui/kritzel-settings/kritzel-settings.js +1 -1
  25. package/dist/collection/components/ui/kritzel-share-dialog/kritzel-share-dialog.js +2 -2
  26. package/dist/collection/components/ui/kritzel-utility-panel/kritzel-utility-panel.js +1 -1
  27. package/dist/collection/components/ui/kritzel-zoom-panel/kritzel-zoom-panel.js +1 -1
  28. package/dist/collection/constants/license.constants.js +1 -1
  29. package/dist/collection/constants/version.js +1 -1
  30. package/dist/collection/helpers/object.helper.js +11 -0
  31. package/dist/collection/helpers/svg-export.helper.js +19 -9
  32. package/dist/collection/helpers/theme.helper.js +8 -0
  33. package/dist/collection/index.js +1 -0
  34. package/dist/collection/interfaces/object-export.interface.js +1 -0
  35. package/dist/collection/interfaces/sync-config.interface.js +21 -1
  36. package/dist/collection/themes/dark-theme.js +2 -0
  37. package/dist/collection/themes/light-theme.js +2 -0
  38. package/dist/components/index.js +11 -4
  39. package/dist/components/kritzel-awareness-cursors.js +1 -1
  40. package/dist/components/kritzel-color-palette.js +1 -1
  41. package/dist/components/kritzel-color.js +1 -1
  42. package/dist/components/kritzel-controls.js +1 -1
  43. package/dist/components/kritzel-editor.js +180 -83
  44. package/dist/components/kritzel-engine.js +1 -1
  45. package/dist/components/kritzel-export.js +1 -1
  46. package/dist/components/kritzel-loading-overlay.js +1 -1
  47. package/dist/components/kritzel-login-dialog.js +1 -1
  48. package/dist/components/kritzel-menu-item.js +1 -1
  49. package/dist/components/kritzel-menu.js +1 -1
  50. package/dist/components/kritzel-more-menu.js +1 -1
  51. package/dist/components/kritzel-portal.js +1 -1
  52. package/dist/components/kritzel-settings.js +1 -1
  53. package/dist/components/kritzel-share-dialog.js +1 -1
  54. package/dist/components/kritzel-split-button.js +1 -1
  55. package/dist/components/kritzel-stroke-size.js +1 -1
  56. package/dist/components/kritzel-tool-config.js +1 -1
  57. package/dist/components/kritzel-utility-panel.js +1 -1
  58. package/dist/components/kritzel-workspace-manager.js +1 -1
  59. package/dist/components/kritzel-zoom-panel.js +1 -1
  60. package/dist/components/{p-DwY5pghH.js → p-6rQICkGr.js} +1 -1
  61. package/dist/components/{p-DkH3hoLz.js → p-8ScektIm.js} +6 -6
  62. package/dist/components/{p-1nG2hxfJ.js → p-9m1cAVwG.js} +2 -2
  63. package/dist/components/{p-DkY7pIg5.js → p-BRGufCki.js} +95 -6
  64. package/dist/components/{p-BKOsmXB5.js → p-BqWiEmYI.js} +1 -1
  65. package/dist/components/{p-BLOPBDUF.js → p-C2CeSrTm.js} +1 -1
  66. package/dist/components/{p-zd7zXld8.js → p-CMZ09rB1.js} +1 -1
  67. package/dist/components/{p-BmyTdQGy.js → p-CURiiQfq.js} +12 -0
  68. package/dist/components/{p-DDbO5q-e.js → p-CXJZxJvp.js} +4 -4
  69. package/dist/components/{p-7Rt7ml3v.js → p-Cj0NElII.js} +1 -1
  70. package/dist/components/{p-Bm_BWQa4.js → p-Ckm1Aq0V.js} +1 -1
  71. package/dist/components/{p-BVEGQLBD.js → p-CoY7emAj.js} +1 -1
  72. package/dist/components/{p-BZxOukfY.js → p-Cr2z2UBL.js} +4 -4
  73. package/dist/components/{p-D0oplzU-.js → p-D7H8oxZb.js} +2 -2
  74. package/dist/components/{p-Dd-pMvlr.js → p-DPONTran.js} +11 -0
  75. package/dist/components/{p-BMRln59F.js → p-DW7WW4EN.js} +2 -2
  76. package/dist/components/{p-D8TOEhPY.js → p-DuVkk58V.js} +2 -2
  77. package/dist/components/{p-OLfoATNk.js → p-U9FjWrp-.js} +1 -1
  78. package/dist/components/{p-C2yi85nc.js → p-ZPPtEla5.js} +1213 -1131
  79. package/dist/components/{p-COjsYPir.js → p-dVfh-Sy9.js} +1 -1
  80. package/dist/components/{p-Ka7OWbnk.js → p-lQ7nfhOy.js} +12 -3
  81. package/dist/esm/index.js +9 -2
  82. package/dist/esm/kritzel-active-users_47.entry.js +1396 -1243
  83. package/dist/esm/loader.js +1 -1
  84. package/dist/esm/{schema.constants-uC7_X6yr.js → schema.constants-1t45c2_U.js} +136 -8
  85. package/dist/esm/stencil.js +1 -1
  86. package/dist/stencil/index.esm.js +9 -2
  87. package/dist/stencil/{p-uC7_X6yr.js → p-1t45c2_U.js} +136 -8
  88. package/dist/stencil/{p-472f2e90.entry.js → p-f5f088ea.entry.js} +1396 -1243
  89. package/dist/stencil/stencil.esm.js +1 -1
  90. package/dist/types/classes/managers/license.manager.d.ts +6 -0
  91. package/dist/types/classes/objects/image.class.d.ts +3 -0
  92. package/dist/types/classes/objects/path.class.d.ts +3 -0
  93. package/dist/types/classes/objects/shape.class.d.ts +3 -0
  94. package/dist/types/classes/providers/sync/broadcast-sync-provider.class.d.ts +1 -0
  95. package/dist/types/classes/providers/sync/hocuspocus-sync-provider.class.d.ts +1 -0
  96. package/dist/types/classes/providers/sync/in-memory-sync-provider.class.d.ts +1 -0
  97. package/dist/types/classes/providers/sync/indexeddb-sync-provider.class.d.ts +1 -0
  98. package/dist/types/classes/providers/sync/websocket-sync-provider.class.d.ts +1 -0
  99. package/dist/types/components/core/kritzel-editor/kritzel-editor.d.ts +15 -3
  100. package/dist/types/components/core/kritzel-engine/kritzel-engine.d.ts +16 -34
  101. package/dist/types/components/ui/kritzel-more-menu/kritzel-more-menu.d.ts +10 -1
  102. package/dist/types/components.d.ts +27 -26
  103. package/dist/types/constants/license.constants.d.ts +1 -1
  104. package/dist/types/constants/version.d.ts +1 -1
  105. package/dist/types/helpers/object.helper.d.ts +4 -0
  106. package/dist/types/helpers/theme.helper.d.ts +1 -0
  107. package/dist/types/index.d.ts +1 -0
  108. package/dist/types/interfaces/menu-item.interface.d.ts +1 -1
  109. package/dist/types/interfaces/object-export.interface.d.ts +20 -0
  110. package/dist/types/interfaces/sync-config.interface.d.ts +5 -0
  111. package/dist/types/interfaces/sync-provider.interface.d.ts +5 -0
  112. package/dist/types/interfaces/theme.interface.d.ts +2 -0
  113. package/package.json +1 -1
@@ -1,18 +1,18 @@
1
1
  import { p as proxyCustomElement, H, c as createEvent, h, d as Host, t as transformTag } from './p-B43upypT.js';
2
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-Ka7OWbnk.js';
3
+ import { K as KritzelBaseObject, e as KritzelMathHelper, f as KritzelGeometryHelper, a as KritzelPath, b as KritzelLine, d as defineCustomElement$6 } from './p-lQ7nfhOy.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-BmyTdQGy.js';
5
+ import { a as KritzelColorHelper, D as DEFAULT_COLOR_PALETTE, K as KritzelThemeManager } from './p-CURiiQfq.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
8
  import { A as ABSOLUTE_SCALE_MAX, a as ABSOLUTE_SCALE_MIN, d as defineCustomElement$1 } from './p-CwuGA058.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
14
  import { K as KritzelIconRegistry, d as defineCustomElement$4 } from './p-CdR76C4L.js';
15
- import { d as defineCustomElement$3 } from './p-BVEGQLBD.js';
15
+ import { d as defineCustomElement$3 } from './p-CoY7emAj.js';
16
16
  import { d as defineCustomElement$2 } from './p-CM1duewg.js';
17
17
 
18
18
  class KritzelKeyboardHelper {
@@ -13877,7 +13877,7 @@ class KritzelText extends KritzelBaseObject {
13877
13877
  }
13878
13878
  element.style.fontFamily = KritzelFontRegistry.resolveCssFontFamily(this.fontFamily);
13879
13879
  element.style.fontSize = `${this.fontSize}pt`;
13880
- element.style.color = KritzelColorHelper.resolveThemeColor(this.fontColor);
13880
+ element.style.color = KritzelColorHelper.resolveThemeColor(this.fontColor, this._core?.themeManager?.currentTheme);
13881
13881
  if (this.isMounted && this.elementRef === element && this.editor.dom.parentElement === element) {
13882
13882
  if (!this._core.store.state.isScaling && !this._core.store.state.isPanning) {
13883
13883
  requestAnimationFrame(() => this.adjustSizeOnInput());
@@ -15453,6 +15453,15 @@ class KritzelImage extends KritzelBaseObject {
15453
15453
  }
15454
15454
  return { scaledWidth, scaledHeight };
15455
15455
  }
15456
+ getSupportedExportFormats() {
15457
+ return ['png'];
15458
+ }
15459
+ async exportAs(format, context) {
15460
+ if (format !== 'png') {
15461
+ return null;
15462
+ }
15463
+ return context.getObjectAsPngDataUrl(this);
15464
+ }
15456
15465
  }
15457
15466
 
15458
15467
  class KritzelClassHelper {
@@ -16219,7 +16228,7 @@ class KritzelShape extends KritzelBaseObject {
16219
16228
  }
16220
16229
  element.style.fontFamily = this.fontFamily;
16221
16230
  element.style.fontSize = `${this.fontSize}pt`;
16222
- element.style.color = KritzelColorHelper.resolveThemeColor(this.fontColor);
16231
+ element.style.color = KritzelColorHelper.resolveThemeColor(this.fontColor, this._core?.themeManager?.currentTheme);
16223
16232
  element.style.whiteSpace = 'pre-wrap';
16224
16233
  element.style.wordWrap = 'break-word';
16225
16234
  if (this.editor.dom.parentElement === element) {
@@ -16535,13 +16544,13 @@ class KritzelShape extends KritzelBaseObject {
16535
16544
  this.fontColor = KritzelColorHelper.determineTextColor(this.fillColor);
16536
16545
  // Update the editor's text color immediately
16537
16546
  if (this.editor?.dom?.parentElement) {
16538
- this.editor.dom.parentElement.style.color = KritzelColorHelper.resolveThemeColor(this.fontColor);
16547
+ this.editor.dom.parentElement.style.color = KritzelColorHelper.resolveThemeColor(this.fontColor, this._core?.themeManager?.currentTheme);
16539
16548
  }
16540
16549
  }
16541
16550
  else if (changedProperties.includes('fontColor')) {
16542
16551
  // Also update editor when fontColor changes directly
16543
16552
  if (this.editor?.dom?.parentElement) {
16544
- this.editor.dom.parentElement.style.color = KritzelColorHelper.resolveThemeColor(this.fontColor);
16553
+ this.editor.dom.parentElement.style.color = KritzelColorHelper.resolveThemeColor(this.fontColor, this._core?.themeManager?.currentTheme);
16545
16554
  }
16546
16555
  }
16547
16556
  }
@@ -16660,6 +16669,15 @@ class KritzelShape extends KritzelBaseObject {
16660
16669
  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
16670
  }
16662
16671
  }
16672
+ getSupportedExportFormats() {
16673
+ return ['svg'];
16674
+ }
16675
+ async exportAs(format, context) {
16676
+ if (format !== 'svg') {
16677
+ return null;
16678
+ }
16679
+ return context.getObjectAsSvgDataUrl(this);
16680
+ }
16663
16681
  }
16664
16682
 
16665
16683
  const KRITZEL_RENDERER_REGISTRY_KEY = '__kritzelCustomElementRendererRegistry__';
@@ -21164,7 +21182,7 @@ class KritzelLineHandleHandler extends KritzelBaseHandler {
21164
21182
  t: clipInfo?.t,
21165
21183
  edgeX: clipInfo?.edgeX,
21166
21184
  edgeY: clipInfo?.edgeY,
21167
- lineStroke: KritzelColorHelper.resolveThemeColor(line.stroke),
21185
+ lineStroke: KritzelColorHelper.resolveThemeColor(line.stroke, this._core.themeManager.currentTheme),
21168
21186
  lineStrokeWidth: line.strokeWidth / line.scale,
21169
21187
  arrowOffset: endpoint === 'start'
21170
21188
  ? (line.hasStartArrow ? line.getArrowSize('start') / line.scale : undefined)
@@ -23839,7 +23857,7 @@ const KRITZEL_LICENSE_TOKEN_PREFIX = 'KRTZL1.';
23839
23857
  * printed public key here. Replacing this value invalidates every license
23840
23858
  * signed with the previous key.
23841
23859
  */
23842
- const KRITZEL_LICENSE_PUBLIC_KEY = 'I-pYJlxDEjT94rwSaqhXW5Sv__fMKS-JKKnV3pg0PaM';
23860
+ const KRITZEL_LICENSE_PUBLIC_KEY = '2IwZDin-qV3kOt8SEQCqdaJsGT9S7tAGapUYJVnVGQo';
23843
23861
 
23844
23862
  /** Minimum delay (ms) between periodic license re-validations. */
23845
23863
  const MIN_REVALIDATION_DELAY_MS = 30_000;
@@ -24072,8 +24090,64 @@ class KritzelLicenseManager {
24072
24090
  if (typeof claims.exp === 'number' && claims.exp * 1000 <= Date.now()) {
24073
24091
  return { status: 'rejected' }; // Cleanly expired.
24074
24092
  }
24093
+ const allowedDomains = this.normalizeAllowedDomains(claims.allowedDomains);
24094
+ if (allowedDomains === null || allowedDomains.length === 0) {
24095
+ return { status: 'rejected' };
24096
+ }
24097
+ const currentHostname = this.getCurrentHostname();
24098
+ if (!currentHostname || !allowedDomains.includes(currentHostname)) {
24099
+ return { status: 'rejected' };
24100
+ }
24075
24101
  return { status: 'pass', payloadSegment, signatureSegment, claims };
24076
24102
  }
24103
+ /** Returns the normalized current hostname, or null when unavailable/invalid. */
24104
+ getCurrentHostname() {
24105
+ const hostname = globalThis.location?.hostname;
24106
+ if (typeof hostname !== 'string') {
24107
+ return null;
24108
+ }
24109
+ return this.normalizeDomain(hostname);
24110
+ }
24111
+ /** Normalizes and validates the allowed domains claim. */
24112
+ normalizeAllowedDomains(domains) {
24113
+ if (!Array.isArray(domains)) {
24114
+ return null;
24115
+ }
24116
+ const normalized = new Set();
24117
+ for (const entry of domains) {
24118
+ if (typeof entry !== 'string') {
24119
+ return null;
24120
+ }
24121
+ const domain = this.normalizeDomain(entry);
24122
+ if (!domain) {
24123
+ return null;
24124
+ }
24125
+ normalized.add(domain);
24126
+ }
24127
+ return Array.from(normalized);
24128
+ }
24129
+ /** Normalizes a domain and rejects malformed hostnames. */
24130
+ normalizeDomain(value) {
24131
+ const trimmed = value.trim().toLowerCase();
24132
+ if (trimmed.length === 0) {
24133
+ return null;
24134
+ }
24135
+ const withoutTrailingDot = trimmed.endsWith('.') ? trimmed.slice(0, -1) : trimmed;
24136
+ if (withoutTrailingDot.length === 0) {
24137
+ return null;
24138
+ }
24139
+ try {
24140
+ const parsed = new URL(`https://${withoutTrailingDot}`);
24141
+ const normalizedHostname = parsed.hostname.toLowerCase();
24142
+ if (normalizedHostname !== withoutTrailingDot) {
24143
+ return null;
24144
+ }
24145
+ return normalizedHostname;
24146
+ }
24147
+ catch {
24148
+ return null;
24149
+ }
24150
+ }
24077
24151
  /**
24078
24152
  * Verifies the Ed25519 signature of the token against the embedded public
24079
24153
  * key. Resolves true/false for a definitive valid/invalid signature.
@@ -46947,953 +47021,311 @@ class KritzelCore {
46947
47021
  }
46948
47022
  }
46949
47023
 
46950
- /**
46951
- * Helper class for exporting Kritzel objects to SVG format.
46952
- * Generates clean, standards-compliant SVG markup from canvas objects.
46953
- */
46954
- class KritzelSvgExportHelper {
46955
- /**
46956
- * Calculates the combined bounding box that encompasses all objects.
46957
- * Uses rotatedBoundingBox to account for object rotation.
46958
- * @param objects - Array of objects to calculate bounds for
46959
- * @returns Combined bounding box, or null if no objects provided
46960
- */
46961
- static calculateCombinedBounds(objects) {
46962
- if (!objects || objects.length === 0) {
46963
- return null;
46964
- }
46965
- let minX = Infinity;
46966
- let minY = Infinity;
46967
- let maxX = -Infinity;
46968
- let maxY = -Infinity;
46969
- for (const obj of objects) {
46970
- const bbox = obj.rotatedBoundingBox;
46971
- minX = Math.min(minX, bbox.x);
46972
- minY = Math.min(minY, bbox.y);
46973
- maxX = Math.max(maxX, bbox.x + bbox.width);
46974
- maxY = Math.max(maxY, bbox.y + bbox.height);
46975
- }
46976
- return {
46977
- x: minX,
46978
- y: minY,
46979
- z: 1,
46980
- width: maxX - minX,
46981
- height: maxY - minY,
46982
- };
47024
+ function resolveUrl(url, baseUrl) {
47025
+ // url is absolute already
47026
+ if (url.match(/^[a-z]+:\/\//i)) {
47027
+ return url;
46983
47028
  }
46984
- /**
46985
- * Generates a complete SVG document string from the given objects.
46986
- * @param objects - Objects to export
46987
- * @param options - Export options
46988
- * @returns SVG document string
46989
- */
46990
- static generateSvg(objects, options = {}) {
46991
- const { theme, padding = 0, includeXmlDeclaration = true, rasterSafeForCanvas = false, imageDataUrls } = options;
46992
- if (!objects || objects.length === 0) {
46993
- return '';
46994
- }
46995
- // Flatten groups to get all objects including nested children
46996
- const allObjects = this.flattenObjects(objects);
46997
- const bounds = this.calculateCombinedBounds(allObjects);
46998
- if (!bounds) {
46999
- return '';
47029
+ // url is absolute already, without protocol
47030
+ if (url.match(/^\/\//)) {
47031
+ return window.location.protocol + url;
47032
+ }
47033
+ // dataURI, mailto:, tel:, etc.
47034
+ if (url.match(/^[a-z]+:/i)) {
47035
+ return url;
47036
+ }
47037
+ const doc = document.implementation.createHTMLDocument();
47038
+ const base = doc.createElement('base');
47039
+ const a = doc.createElement('a');
47040
+ doc.head.appendChild(base);
47041
+ doc.body.appendChild(a);
47042
+ if (baseUrl) {
47043
+ base.href = baseUrl;
47044
+ }
47045
+ a.href = url;
47046
+ return a.href;
47047
+ }
47048
+ const uuid = (() => {
47049
+ // generate uuid for className of pseudo elements.
47050
+ // We should not use GUIDs, otherwise pseudo elements sometimes cannot be captured.
47051
+ let counter = 0;
47052
+ // ref: http://stackoverflow.com/a/6248722/2519373
47053
+ const random = () =>
47054
+ // eslint-disable-next-line no-bitwise
47055
+ `0000${((Math.random() * 36 ** 4) << 0).toString(36)}`.slice(-4);
47056
+ return () => {
47057
+ counter += 1;
47058
+ return `u${random()}${counter}`;
47059
+ };
47060
+ })();
47061
+ function toArray(arrayLike) {
47062
+ const arr = [];
47063
+ for (let i = 0, l = arrayLike.length; i < l; i++) {
47064
+ arr.push(arrayLike[i]);
47065
+ }
47066
+ return arr;
47067
+ }
47068
+ let styleProps = null;
47069
+ function getStyleProperties(options = {}) {
47070
+ if (styleProps) {
47071
+ return styleProps;
47072
+ }
47073
+ if (options.includeStyleProperties) {
47074
+ styleProps = options.includeStyleProperties;
47075
+ return styleProps;
47076
+ }
47077
+ styleProps = toArray(window.getComputedStyle(document.documentElement));
47078
+ return styleProps;
47079
+ }
47080
+ function px(node, styleProperty) {
47081
+ const win = node.ownerDocument.defaultView || window;
47082
+ const val = win.getComputedStyle(node).getPropertyValue(styleProperty);
47083
+ return val ? parseFloat(val.replace('px', '')) : 0;
47084
+ }
47085
+ function getNodeWidth(node) {
47086
+ const leftBorder = px(node, 'border-left-width');
47087
+ const rightBorder = px(node, 'border-right-width');
47088
+ return node.clientWidth + leftBorder + rightBorder;
47089
+ }
47090
+ function getNodeHeight(node) {
47091
+ const topBorder = px(node, 'border-top-width');
47092
+ const bottomBorder = px(node, 'border-bottom-width');
47093
+ return node.clientHeight + topBorder + bottomBorder;
47094
+ }
47095
+ function getImageSize(targetNode, options = {}) {
47096
+ const width = options.width || getNodeWidth(targetNode);
47097
+ const height = options.height || getNodeHeight(targetNode);
47098
+ return { width, height };
47099
+ }
47100
+ function getPixelRatio() {
47101
+ let ratio;
47102
+ let FINAL_PROCESS;
47103
+ try {
47104
+ FINAL_PROCESS = process;
47105
+ }
47106
+ catch (e) {
47107
+ // pass
47108
+ }
47109
+ const val = FINAL_PROCESS && FINAL_PROCESS.env
47110
+ ? FINAL_PROCESS.env.devicePixelRatio
47111
+ : null;
47112
+ if (val) {
47113
+ ratio = parseInt(val, 10);
47114
+ if (Number.isNaN(ratio)) {
47115
+ ratio = 1;
47000
47116
  }
47001
- // Add padding to bounds
47002
- const viewBoxX = bounds.x - padding;
47003
- const viewBoxY = bounds.y - padding;
47004
- const viewBoxWidth = bounds.width + padding * 2;
47005
- const viewBoxHeight = bounds.height + padding * 2;
47006
- // Collect all defs (markers, patterns, etc.)
47007
- const defs = this.collectDefs(allObjects, theme);
47008
- // Generate SVG elements for each object
47009
- const elements = objects
47010
- .sort((a, b) => a.zIndex - b.zIndex)
47011
- .map(obj => this.objectToSvgElement(obj, theme, imageDataUrls, rasterSafeForCanvas))
47012
- .filter(Boolean)
47013
- .join('\n ');
47014
- const xmlDecl = includeXmlDeclaration ? '<?xml version="1.0" encoding="UTF-8"?>\n' : '';
47015
- const defsContent = defs.length > 0 ? `\n <defs>\n ${defs.join('\n ')}\n </defs>` : '';
47016
- return `${xmlDecl}<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBoxX} ${viewBoxY} ${viewBoxWidth} ${viewBoxHeight}" width="${viewBoxWidth}" height="${viewBoxHeight}">${defsContent}
47017
- ${elements}
47018
- </svg>`;
47019
47117
  }
47020
- /**
47021
- * Flattens a list of objects, expanding groups into their children recursively.
47022
- * @param objects - Objects to flatten
47023
- * @returns Flat array of all objects including group children
47024
- */
47025
- static flattenObjects(objects) {
47026
- const result = [];
47027
- for (const obj of objects) {
47028
- if (KritzelClassHelper.isInstanceOf(obj, 'KritzelGroup')) {
47029
- result.push(...this.flattenObjects(obj.children));
47118
+ return ratio || window.devicePixelRatio || 1;
47119
+ }
47120
+ // @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#maximum_canvas_size
47121
+ const canvasDimensionLimit = 16384;
47122
+ function checkCanvasDimensions(canvas) {
47123
+ if (canvas.width > canvasDimensionLimit ||
47124
+ canvas.height > canvasDimensionLimit) {
47125
+ if (canvas.width > canvasDimensionLimit &&
47126
+ canvas.height > canvasDimensionLimit) {
47127
+ if (canvas.width > canvas.height) {
47128
+ canvas.height *= canvasDimensionLimit / canvas.width;
47129
+ canvas.width = canvasDimensionLimit;
47030
47130
  }
47031
47131
  else {
47032
- result.push(obj);
47132
+ canvas.width *= canvasDimensionLimit / canvas.height;
47133
+ canvas.height = canvasDimensionLimit;
47033
47134
  }
47034
47135
  }
47035
- return result;
47036
- }
47037
- /**
47038
- * Resolves every image referenced by the given objects (including those
47039
- * nested inside groups) to an inlined `data:` URL, keyed by object id.
47040
- *
47041
- * This is required for export flows that rasterize the SVG (e.g. PNG
47042
- * export): an SVG loaded into an `<img>` element will NOT load external
47043
- * or `blob:` resources, so a `<image href="blob:...">` renders as a
47044
- * broken-image placeholder. Embedding the bytes as a base64 `data:` URL
47045
- * makes the image self-contained and survive rasterization.
47046
- *
47047
- * Resolution is best-effort: images that fail to resolve are simply
47048
- * omitted from the map, and the SVG falls back to whatever `href` the
47049
- * synchronous path would have produced.
47050
- * @param objects - Objects to inline images for
47051
- * @param assetResolver - Resolver used to fetch asset bytes; optional
47052
- * @returns Map of image object id -> inlined `data:` URL
47053
- */
47054
- static async resolveImageDataUrls(objects, assetResolver) {
47055
- const dataUrls = new Map();
47056
- if (!objects || objects.length === 0) {
47057
- return dataUrls;
47136
+ else if (canvas.width > canvasDimensionLimit) {
47137
+ canvas.height *= canvasDimensionLimit / canvas.width;
47138
+ canvas.width = canvasDimensionLimit;
47139
+ }
47140
+ else {
47141
+ canvas.width *= canvasDimensionLimit / canvas.height;
47142
+ canvas.height = canvasDimensionLimit;
47058
47143
  }
47059
- const images = this.flattenObjects(objects).filter((obj) => KritzelClassHelper.isInstanceOf(obj, 'KritzelImage'));
47060
- await Promise.all(images.map(async (image) => {
47061
- // Legacy inline data URL is already embeddable as-is.
47062
- if (image.src?.startsWith('data:')) {
47063
- dataUrls.set(image.id, image.src);
47064
- return;
47065
- }
47066
- // A resolvedSrc that is itself a data URL can be embedded directly.
47067
- if (image.resolvedSrc?.startsWith('data:')) {
47068
- dataUrls.set(image.id, image.resolvedSrc);
47069
- return;
47070
- }
47071
- if (!image.assetId || !assetResolver) {
47072
- return;
47073
- }
47074
- try {
47075
- const blob = await assetResolver.fetchBlob(image.assetId);
47076
- dataUrls.set(image.id, await this.blobToDataUrl(blob));
47077
- }
47078
- catch (err) {
47079
- console.warn(`[KritzelSvgExportHelper] Failed to inline image asset ${image.assetId} for export:`, err);
47080
- }
47081
- }));
47082
- return dataUrls;
47083
47144
  }
47084
- /**
47085
- * Reads a Blob into a base64 `data:` URL.
47086
- * @param blob - The blob to encode
47087
- * @returns Promise resolving to the data URL string
47088
- */
47089
- static async blobToDataUrl(blob) {
47090
- // Prefer FileReader in browser-like runtimes for efficiency.
47091
- const hasFileReader = typeof FileReader !== 'undefined';
47092
- if (hasFileReader) {
47145
+ }
47146
+ function createImage(url) {
47147
+ return new Promise((resolve, reject) => {
47148
+ const img = new Image();
47149
+ img.onload = () => {
47150
+ img.decode().then(() => {
47151
+ requestAnimationFrame(() => resolve(img));
47152
+ });
47153
+ };
47154
+ img.onerror = reject;
47155
+ img.crossOrigin = 'anonymous';
47156
+ img.decoding = 'async';
47157
+ img.src = url;
47158
+ });
47159
+ }
47160
+ async function svgToDataURL(svg) {
47161
+ return Promise.resolve()
47162
+ .then(() => new XMLSerializer().serializeToString(svg))
47163
+ .then(encodeURIComponent)
47164
+ .then((html) => `data:image/svg+xml;charset=utf-8,${html}`);
47165
+ }
47166
+ async function nodeToDataURL(node, width, height) {
47167
+ const xmlns = 'http://www.w3.org/2000/svg';
47168
+ const svg = document.createElementNS(xmlns, 'svg');
47169
+ const foreignObject = document.createElementNS(xmlns, 'foreignObject');
47170
+ svg.setAttribute('width', `${width}`);
47171
+ svg.setAttribute('height', `${height}`);
47172
+ svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
47173
+ foreignObject.setAttribute('width', '100%');
47174
+ foreignObject.setAttribute('height', '100%');
47175
+ foreignObject.setAttribute('x', '0');
47176
+ foreignObject.setAttribute('y', '0');
47177
+ foreignObject.setAttribute('externalResourcesRequired', 'true');
47178
+ svg.appendChild(foreignObject);
47179
+ foreignObject.appendChild(node);
47180
+ return svgToDataURL(svg);
47181
+ }
47182
+ const isInstanceOfElement = (node, instance) => {
47183
+ if (node instanceof instance)
47184
+ return true;
47185
+ const nodePrototype = Object.getPrototypeOf(node);
47186
+ if (nodePrototype === null)
47187
+ return false;
47188
+ return (nodePrototype.constructor.name === instance.name ||
47189
+ isInstanceOfElement(nodePrototype, instance));
47190
+ };
47191
+
47192
+ function formatCSSText(style) {
47193
+ const content = style.getPropertyValue('content');
47194
+ return `${style.cssText} content: '${content.replace(/'|"/g, '')}';`;
47195
+ }
47196
+ function formatCSSProperties(style, options) {
47197
+ return getStyleProperties(options)
47198
+ .map((name) => {
47199
+ const value = style.getPropertyValue(name);
47200
+ const priority = style.getPropertyPriority(name);
47201
+ return `${name}: ${value}${priority ? ' !important' : ''};`;
47202
+ })
47203
+ .join(' ');
47204
+ }
47205
+ function getPseudoElementStyle(className, pseudo, style, options) {
47206
+ const selector = `.${className}:${pseudo}`;
47207
+ const cssText = style.cssText
47208
+ ? formatCSSText(style)
47209
+ : formatCSSProperties(style, options);
47210
+ return document.createTextNode(`${selector}{${cssText}}`);
47211
+ }
47212
+ function clonePseudoElement(nativeNode, clonedNode, pseudo, options) {
47213
+ const style = window.getComputedStyle(nativeNode, pseudo);
47214
+ const content = style.getPropertyValue('content');
47215
+ if (content === '' || content === 'none') {
47216
+ return;
47217
+ }
47218
+ const className = uuid();
47219
+ try {
47220
+ clonedNode.className = `${clonedNode.className} ${className}`;
47221
+ }
47222
+ catch (err) {
47223
+ return;
47224
+ }
47225
+ const styleElement = document.createElement('style');
47226
+ styleElement.appendChild(getPseudoElementStyle(className, pseudo, style, options));
47227
+ clonedNode.appendChild(styleElement);
47228
+ }
47229
+ function clonePseudoElements(nativeNode, clonedNode, options) {
47230
+ clonePseudoElement(nativeNode, clonedNode, ':before', options);
47231
+ clonePseudoElement(nativeNode, clonedNode, ':after', options);
47232
+ }
47233
+
47234
+ const WOFF = 'application/font-woff';
47235
+ const JPEG = 'image/jpeg';
47236
+ const mimes = {
47237
+ woff: WOFF,
47238
+ woff2: WOFF,
47239
+ ttf: 'application/font-truetype',
47240
+ eot: 'application/vnd.ms-fontobject',
47241
+ png: 'image/png',
47242
+ jpg: JPEG,
47243
+ jpeg: JPEG,
47244
+ gif: 'image/gif',
47245
+ tiff: 'image/tiff',
47246
+ svg: 'image/svg+xml',
47247
+ webp: 'image/webp',
47248
+ };
47249
+ function getExtension(url) {
47250
+ const match = /\.([^./]*?)$/g.exec(url);
47251
+ return match ? match[1] : '';
47252
+ }
47253
+ function getMimeType(url) {
47254
+ const extension = getExtension(url).toLowerCase();
47255
+ return mimes[extension] || '';
47256
+ }
47257
+
47258
+ function getContentFromDataUrl(dataURL) {
47259
+ return dataURL.split(/,/)[1];
47260
+ }
47261
+ function isDataUrl(url) {
47262
+ return url.search(/^(data:)/) !== -1;
47263
+ }
47264
+ function makeDataUrl(content, mimeType) {
47265
+ return `data:${mimeType};base64,${content}`;
47266
+ }
47267
+ async function fetchAsDataURL(url, init, process) {
47268
+ const res = await fetch(url, init);
47269
+ if (res.status === 404) {
47270
+ throw new Error(`Resource "${res.url}" not found`);
47271
+ }
47272
+ const blob = await res.blob();
47273
+ return new Promise((resolve, reject) => {
47274
+ const reader = new FileReader();
47275
+ reader.onerror = reject;
47276
+ reader.onloadend = () => {
47093
47277
  try {
47094
- return await new Promise((resolve, reject) => {
47095
- const reader = new FileReader();
47096
- reader.onload = () => {
47097
- if (typeof reader.result === 'string') {
47098
- resolve(reader.result);
47099
- return;
47100
- }
47101
- reject(new Error('FileReader result is not a string'));
47102
- };
47103
- reader.onerror = () => reject(reader.error ?? new Error('Failed to read blob as data URL'));
47104
- reader.readAsDataURL(blob);
47105
- });
47278
+ resolve(process({ res, result: reader.result }));
47106
47279
  }
47107
- catch {
47108
- // Fall through to the ArrayBuffer-based encoder.
47280
+ catch (error) {
47281
+ reject(error);
47109
47282
  }
47110
- }
47111
- const bytes = new Uint8Array(await blob.arrayBuffer());
47112
- const mimeType = blob.type || 'application/octet-stream';
47113
- return `data:${mimeType};base64,${this.uint8ToBase64(bytes)}`;
47283
+ };
47284
+ reader.readAsDataURL(blob);
47285
+ });
47286
+ }
47287
+ const cache = {};
47288
+ function getCacheKey(url, contentType, includeQueryParams) {
47289
+ let key = url.replace(/\?.*/, '');
47290
+ if (includeQueryParams) {
47291
+ key = url;
47114
47292
  }
47115
- /**
47116
- * Encodes a byte array to base64 across browser and Node-like runtimes.
47117
- */
47118
- static uint8ToBase64(bytes) {
47119
- if (typeof Buffer !== 'undefined') {
47120
- return Buffer.from(bytes).toString('base64');
47121
- }
47122
- let binary = '';
47123
- const chunkSize = 0x8000;
47124
- for (let i = 0; i < bytes.length; i += chunkSize) {
47125
- const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length));
47126
- binary += String.fromCharCode(...chunk);
47127
- }
47128
- if (typeof btoa === 'function') {
47129
- return btoa(binary);
47130
- }
47131
- throw new Error('No base64 encoder available in this runtime');
47293
+ // font resource
47294
+ if (/ttf|otf|eot|woff2?/i.test(key)) {
47295
+ key = key.replace(/.*\//, '');
47132
47296
  }
47133
- /**
47134
- * Collects SVG defs (markers, patterns, etc.) needed by the objects.
47135
- * @param objects - Objects to collect defs for
47136
- * @param theme - Theme for color resolution
47137
- * @returns Array of def element strings
47138
- */
47139
- static collectDefs(objects, theme) {
47140
- const defs = [];
47141
- for (const obj of objects) {
47142
- if (KritzelClassHelper.isInstanceOf(obj, 'KritzelLine')) {
47143
- if (obj.hasStartArrow) {
47144
- defs.push(this.generateArrowMarker(obj, 'start', theme));
47145
- }
47146
- if (obj.hasEndArrow) {
47147
- defs.push(this.generateArrowMarker(obj, 'end', theme));
47148
- }
47297
+ return contentType ? `[${contentType}]${key}` : key;
47298
+ }
47299
+ async function resourceToDataURL(resourceUrl, contentType, options) {
47300
+ const cacheKey = getCacheKey(resourceUrl, contentType, options.includeQueryParams);
47301
+ if (cache[cacheKey] != null) {
47302
+ return cache[cacheKey];
47303
+ }
47304
+ // ref: https://developer.mozilla.org/en/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest#Bypassing_the_cache
47305
+ if (options.cacheBust) {
47306
+ // eslint-disable-next-line no-param-reassign
47307
+ resourceUrl += (/\?/.test(resourceUrl) ? '&' : '?') + new Date().getTime();
47308
+ }
47309
+ let dataURL;
47310
+ try {
47311
+ const content = await fetchAsDataURL(resourceUrl, options.fetchRequestInit, ({ res, result }) => {
47312
+ if (!contentType) {
47313
+ // eslint-disable-next-line no-param-reassign
47314
+ contentType = res.headers.get('Content-Type') || '';
47149
47315
  }
47316
+ return getContentFromDataUrl(result);
47317
+ });
47318
+ dataURL = makeDataUrl(content, contentType);
47319
+ }
47320
+ catch (error) {
47321
+ dataURL = options.imagePlaceholder || '';
47322
+ let msg = `Failed to fetch resource: ${resourceUrl}`;
47323
+ if (error) {
47324
+ msg = typeof error === 'string' ? error : error.message;
47325
+ }
47326
+ if (msg) {
47327
+ console.warn(msg);
47150
47328
  }
47151
- return defs;
47152
- }
47153
- /**
47154
- * Generates an SVG marker element for line arrows.
47155
- * @param line - The line object
47156
- * @param end - Which end of the line ('start' or 'end')
47157
- * @param theme - Theme for color resolution
47158
- * @returns SVG marker element string
47159
- */
47160
- static generateArrowMarker(line, end, _theme) {
47161
- const markerId = end === 'start' ? line.startMarkerId : line.endMarkerId;
47162
- const size = line.getArrowSize(end);
47163
- const fill = line.getArrowFill(end);
47164
- const style = end === 'start' ? line.arrows?.start?.style : line.arrows?.end?.style;
47165
- const path = line.getArrowPath(style);
47166
- const orient = end === 'start' ? 'auto-start-reverse' : 'auto';
47167
- const refX = 0;
47168
- const refY = size / 2;
47169
- return `<marker id="${markerId}" markerWidth="${size}" markerHeight="${size}" refX="${refX}" refY="${refY}" orient="${orient}" markerUnits="userSpaceOnUse">
47170
- <path d="${path}" fill="${fill}" transform="scale(${size / 10})"/>
47171
- </marker>`;
47172
- }
47173
- /**
47174
- * Converts a Kritzel object to its SVG element representation.
47175
- * @param object - The object to convert
47176
- * @param theme - Theme for color resolution
47177
- * @param imageDataUrls - Optional map of image id -> inlined data URL
47178
- * @returns SVG element string, or empty string if object type is not supported
47179
- */
47180
- static objectToSvgElement(object, theme, imageDataUrls, rasterSafeForCanvas = false) {
47181
- if (KritzelClassHelper.isInstanceOf(object, 'KritzelPath')) {
47182
- return this.pathToSvg(object, theme);
47183
- }
47184
- if (KritzelClassHelper.isInstanceOf(object, 'KritzelLine')) {
47185
- return this.lineToSvg(object, theme);
47186
- }
47187
- if (KritzelClassHelper.isInstanceOf(object, 'KritzelShape')) {
47188
- return this.shapeToSvg(object, theme);
47189
- }
47190
- if (KritzelClassHelper.isInstanceOf(object, 'KritzelText')) {
47191
- return this.textToSvg(object, theme, rasterSafeForCanvas);
47192
- }
47193
- if (KritzelClassHelper.isInstanceOf(object, 'KritzelImage')) {
47194
- return this.imageToSvg(object, imageDataUrls);
47195
- }
47196
- if (KritzelClassHelper.isInstanceOf(object, 'KritzelCustomElement')) {
47197
- return this.customElementToSvg(object, rasterSafeForCanvas);
47198
- }
47199
- if (KritzelClassHelper.isInstanceOf(object, 'KritzelGroup')) {
47200
- return this.groupToSvg(object, theme, imageDataUrls, rasterSafeForCanvas);
47201
- }
47202
- return '';
47203
- }
47204
- /**
47205
- * Converts a KritzelPath to SVG.
47206
- * @param path - The path object
47207
- * @param theme - Theme for color resolution
47208
- * @returns SVG element string
47209
- */
47210
- static pathToSvg(path, theme) {
47211
- const transform = this.buildTransform(path);
47212
- const fill = KritzelColorHelper.resolveThemeColor(path.fill, theme);
47213
- const stroke = KritzelColorHelper.resolveThemeColor(path.stroke, theme);
47214
- const opacity = path.opacity !== 1 ? ` opacity="${path.opacity}"` : '';
47215
- const scale = this.getObjectScale(path);
47216
- // Path needs to be wrapped in a g with translation since path.d uses local coordinates
47217
- return `<g transform="${transform}"${opacity}>
47218
- <svg viewBox="${path.viewBox}" width="${path.totalWidth / scale}" height="${path.totalHeight / scale}" overflow="visible">
47219
- <path d="${path.d}" fill="${fill || 'none'}" stroke="${stroke || 'none'}"${path.strokeWidth ? ` stroke-width="${path.strokeWidth}"` : ''}/>
47220
- </svg>
47221
- </g>`;
47222
- }
47223
- /**
47224
- * Converts a KritzelLine to SVG.
47225
- * @param line - The line object
47226
- * @param theme - Theme for color resolution
47227
- * @returns SVG element string
47228
- */
47229
- static lineToSvg(line, theme) {
47230
- const transform = this.buildTransform(line);
47231
- const stroke = KritzelColorHelper.resolveThemeColor(line.stroke, theme);
47232
- const opacity = line.opacity !== 1 ? ` opacity="${line.opacity}"` : '';
47233
- const scale = this.getObjectScale(line);
47234
- const markerStart = line.hasStartArrow ? ` marker-start="url(#${line.startMarkerId})"` : '';
47235
- const markerEnd = line.hasEndArrow ? ` marker-end="url(#${line.endMarkerId})"` : '';
47236
- return `<g transform="${transform}"${opacity}>
47237
- <svg viewBox="${line.viewBox}" width="${line.totalWidth / scale}" height="${line.totalHeight / scale}" overflow="visible">
47238
- <path d="${line.d}" fill="none" stroke="${stroke}" stroke-width="${line.strokeWidth}" stroke-linecap="round"${markerStart}${markerEnd}/>
47239
- </svg>
47240
- </g>`;
47241
- }
47242
- /**
47243
- * Converts a KritzelShape to SVG.
47244
- * @param shape - The shape object
47245
- * @param theme - Theme for color resolution
47246
- * @returns SVG element string
47247
- */
47248
- static shapeToSvg(shape, theme) {
47249
- const transform = this.buildTransform(shape);
47250
- const fill = KritzelColorHelper.resolveThemeColor(shape.fillColor, theme);
47251
- const stroke = KritzelColorHelper.resolveThemeColor(shape.strokeColor, theme);
47252
- const opacity = shape.opacity !== 1 ? ` opacity="${shape.opacity}"` : '';
47253
- const scale = this.getObjectScale(shape);
47254
- // Get the SVG path for the shape
47255
- const pathD = shape.getSvgPath();
47256
- // Handle text content if present
47257
- let textContent = '';
47258
- if (shape.editor && !shape.editor.state.doc.textContent.trim()) ;
47259
- else if (shape.content) {
47260
- const textHtml = this.prosemirrorToHtml(shape.content, shape, theme);
47261
- if (textHtml) {
47262
- const fontColor = KritzelColorHelper.resolveThemeColor(shape.fontColor, theme);
47263
- textContent = `
47264
- <foreignObject x="${shape.x}" y="${shape.y}" width="${shape.width}" height="${shape.height}">
47265
- <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;">
47266
- ${textHtml}
47267
- </div>
47268
- </foreignObject>`;
47269
- }
47270
- }
47271
- return `<g transform="${transform}"${opacity}>
47272
- <svg viewBox="${shape.viewBox}" width="${shape.totalWidth / scale}" height="${shape.totalHeight / scale}" overflow="visible" preserveAspectRatio="none">
47273
- <path d="${pathD}" fill="${fill || 'transparent'}" stroke="${stroke}" stroke-width="${shape.strokeWidth}"/>${textContent}
47274
- </svg>
47275
- </g>`;
47276
- }
47277
- /**
47278
- * Converts a KritzelText to SVG using foreignObject for rich text support.
47279
- * @param text - The text object
47280
- * @param theme - Theme for color resolution
47281
- * @returns SVG element string
47282
- */
47283
- static textToSvg(text, theme, rasterSafeForCanvas = false) {
47284
- if (rasterSafeForCanvas) {
47285
- return this.textToSvgRasterSafe(text, theme);
47286
- }
47287
- const transform = this.buildTransform(text);
47288
- const opacity = text.opacity !== 1 ? ` opacity="${text.opacity}"` : '';
47289
- const fontColor = KritzelColorHelper.resolveThemeColor(text.fontColor, theme);
47290
- const bgColor = KritzelColorHelper.resolveThemeColor(text.backgroundColor, theme);
47291
- const scale = this.getObjectScale(text);
47292
- // Convert ProseMirror content to HTML
47293
- const htmlContent = this.prosemirrorToHtml(text.content, text, theme);
47294
- // Calculate dimensions accounting for scale
47295
- const width = text.totalWidth / scale;
47296
- const height = text.totalHeight / scale;
47297
- return `<g transform="${transform}"${opacity}>
47298
- <foreignObject x="0" y="0" width="${width}" height="${height}">
47299
- <div xmlns="http://www.w3.org/1999/xhtml" style="font-family: ${text.fontFamily}; font-size: ${text.fontSize}pt; color: ${fontColor}; background-color: ${bgColor || 'transparent'}; transform: scale(${text.scaleFactor}); transform-origin: top left; white-space: pre-wrap; word-wrap: break-word;">
47300
- ${htmlContent}
47301
- </div>
47302
- </foreignObject>
47303
- </g>`;
47304
- }
47305
- /**
47306
- * Converts a KritzelText to SVG using pure SVG text primitives.
47307
- * This path avoids `<foreignObject>`, which taints canvases when the
47308
- * generated SVG is rasterized through `<img>` and `drawImage`.
47309
- */
47310
- static textToSvgRasterSafe(text, theme) {
47311
- const transform = this.buildTransform(text);
47312
- const opacity = text.opacity !== 1 ? ` opacity="${text.opacity}"` : '';
47313
- const fontColor = KritzelColorHelper.resolveThemeColor(text.fontColor, theme);
47314
- const bgColor = KritzelColorHelper.resolveThemeColor(text.backgroundColor, theme);
47315
- const scale = this.getObjectScale(text);
47316
- const width = text.totalWidth / scale;
47317
- const height = text.totalHeight / scale;
47318
- const lines = this.prosemirrorToPlainTextLines(text.content);
47319
- const resolvedLines = lines.length > 0 ? lines : [''];
47320
- const resolvedFontSize = Math.max(1, text.fontSize * (text.scaleFactor || 1));
47321
- const lineHeight = resolvedFontSize * 1.2;
47322
- const startY = resolvedFontSize;
47323
- const bgRect = bgColor && bgColor !== 'transparent'
47324
- ? `<rect x="0" y="0" width="${width}" height="${height}" fill="${bgColor}"/>`
47325
- : '';
47326
- const tspans = resolvedLines
47327
- .map((line, index) => {
47328
- const y = startY + index * lineHeight;
47329
- return `<tspan x="0" y="${y}" xml:space="preserve">${this.escapeHtml(line)}</tspan>`;
47330
- })
47331
- .join('');
47332
- return `<g transform="${transform}"${opacity}>
47333
- ${bgRect}
47334
- <text font-family="${this.escapeHtml(text.fontFamily)}" font-size="${resolvedFontSize}" fill="${fontColor}">${tspans}</text>
47335
- </g>`;
47336
- }
47337
- /**
47338
- * Converts a KritzelImage to SVG.
47339
- *
47340
- * Prefers an inlined `data:` URL from `imageDataUrls` (keyed by object
47341
- * id) when provided, so the embedded bytes survive rasterization to
47342
- * PNG. Falls back to `resolvedSrc` (populated by the renderer via the
47343
- * asset resolver) and finally the legacy inline `src` field for
47344
- * documents persisted before the asset layer existed.
47345
- *
47346
- * Note: a `blob:` `resolvedSrc` renders correctly on-canvas but becomes
47347
- * a broken-image placeholder once the SVG is loaded into an `<img>` and
47348
- * drawn to a canvas. Callers that rasterize the SVG must pass
47349
- * `imageDataUrls` (see {@link KritzelSvgExportHelper.resolveImageDataUrls}).
47350
- * @param image - The image object
47351
- * @param imageDataUrls - Optional map of image id -> inlined data URL
47352
- * @returns SVG element string
47353
- */
47354
- static imageToSvg(image, imageDataUrls) {
47355
- const transform = this.buildTransform(image);
47356
- const opacity = image.opacity !== 1 ? ` opacity="${image.opacity}"` : '';
47357
- const scale = this.getObjectScale(image);
47358
- // Calculate dimensions
47359
- const width = image.totalWidth / scale;
47360
- const height = image.totalHeight / scale;
47361
- const href = imageDataUrls?.get(image.id) || image.resolvedSrc || image.src || '';
47362
- return `<g transform="${transform}"${opacity}>
47363
- <image href="${href}" x="0" y="0" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet"/>
47364
- </g>`;
47365
- }
47366
- /**
47367
- * Converts a KritzelCustomElement to SVG.
47368
- * SVG mode: embeds HTML in foreignObject (best-effort).
47369
- * PNG/raster mode: renders placeholder rect with warning.
47370
- * @param obj - The custom element object
47371
- * @param rasterSafeForCanvas - If true, render placeholder (for PNG export)
47372
- * @returns SVG element string
47373
- */
47374
- static customElementToSvg(obj, rasterSafeForCanvas = false) {
47375
- const transform = this.buildTransform(obj);
47376
- const opacity = obj.opacity !== 1 ? ` opacity="${obj.opacity}"` : '';
47377
- const scale = this.getObjectScale(obj);
47378
- const width = obj.totalWidth / scale;
47379
- const height = obj.totalHeight / scale;
47380
- if (rasterSafeForCanvas) {
47381
- // PNG export: render placeholder rect (foreignObject doesn't rasterize to PNG well)
47382
- console.warn('[KritzelCustomElement SVG Export] Custom elements do not serialize well to PNG. Use SVG export or screenshot instead.');
47383
- return `<g transform="${transform}"${opacity}>
47384
- <rect x="0" y="0" width="${width}" height="${height}" fill="#f0f0f0" stroke="#999" stroke-width="1" />
47385
- <text x="${width / 2}" y="${height / 2}" text-anchor="middle" dominant-baseline="middle" font-size="12" fill="#999">[Custom Element]</text>
47386
- </g>`;
47387
- }
47388
- // SVG export: use foreignObject to embed HTML
47389
- const html = obj.element?.outerHTML || '<div style="border:1px dashed #ccc">Empty</div>';
47390
- return `<g transform="${transform}"${opacity}>
47391
- <foreignObject x="0" y="0" width="${width}" height="${height}">
47392
- <div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;overflow:hidden;">
47393
- ${html}
47394
- </div>
47395
- </foreignObject>
47396
- </g>`;
47397
- }
47398
- /**
47399
- * Converts a KritzelGroup to SVG by recursively converting children.
47400
- * @param group - The group object
47401
- * @param theme - Theme for color resolution
47402
- * @param imageDataUrls - Optional map of image id -> inlined data URL
47403
- * @returns SVG element string
47404
- */
47405
- static groupToSvg(group, theme, imageDataUrls, rasterSafeForCanvas = false) {
47406
- const children = group.children
47407
- .sort((a, b) => a.zIndex - b.zIndex)
47408
- .map(child => this.objectToSvgElement(child, theme, imageDataUrls, rasterSafeForCanvas))
47409
- .filter(Boolean)
47410
- .join('\n ');
47411
- if (!children) {
47412
- return '';
47413
- }
47414
- const opacity = group.opacity !== 1 ? ` opacity="${group.opacity}"` : '';
47415
- return `<g${opacity}>
47416
- ${children}
47417
- </g>`;
47418
- }
47419
- /**
47420
- * Builds a transform attribute string for an object.
47421
- * Combines translation and rotation.
47422
- * @param object - The object to build transform for
47423
- * @returns Transform attribute value string
47424
- */
47425
- static buildTransform(object) {
47426
- const transforms = [];
47427
- const scale = this.getObjectScale(object);
47428
- // Translation
47429
- transforms.push(`translate(${object.translateX}, ${object.translateY})`);
47430
- // Rotation around center
47431
- if (object.rotation !== 0) {
47432
- const centerX = object.totalWidth / 2 / scale;
47433
- const centerY = object.totalHeight / 2 / scale;
47434
- const degrees = object.rotation * (180 / Math.PI);
47435
- transforms.push(`rotate(${degrees}, ${centerX}, ${centerY})`);
47436
- }
47437
- return transforms.join(' ');
47438
- }
47439
- /**
47440
- * Returns a safe non-zero scale for export geometry calculations.
47441
- */
47442
- static getObjectScale(object) {
47443
- const scale = object.scale ?? 1;
47444
- return scale === 0 ? 1 : scale;
47445
- }
47446
- /**
47447
- * Converts ProseMirror JSON content to HTML string.
47448
- * @param content - ProseMirror document JSON
47449
- * @param context - The text or shape object for styling context
47450
- * @param theme - Theme for color resolution
47451
- * @returns HTML string
47452
- */
47453
- static prosemirrorToHtml(content, _context, theme) {
47454
- if (!content || !content.content) {
47455
- return '';
47456
- }
47457
- return content.content
47458
- .map((node) => this.nodeToHtml(node, theme))
47459
- .join('');
47460
- }
47461
- /**
47462
- * Converts a ProseMirror node to HTML.
47463
- * @param node - ProseMirror node JSON
47464
- * @param theme - Theme for color resolution
47465
- * @returns HTML string
47466
- */
47467
- static nodeToHtml(node, theme) {
47468
- if (!node)
47469
- return '';
47470
- switch (node.type) {
47471
- case 'paragraph':
47472
- const pContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
47473
- return `<p style="margin: 0;">${pContent || '<br/>'}</p>`;
47474
- case 'text':
47475
- let text = this.escapeHtml(node.text || '');
47476
- if (node.marks) {
47477
- for (const mark of node.marks) {
47478
- text = this.applyMark(text, mark, theme);
47479
- }
47480
- }
47481
- return text;
47482
- case 'hard_break':
47483
- return '<br/>';
47484
- case 'bullet_list':
47485
- const blContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
47486
- return `<ul style="margin: 0; padding-left: 1.5em;">${blContent}</ul>`;
47487
- case 'ordered_list':
47488
- const olContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
47489
- return `<ol style="margin: 0; padding-left: 1.5em;">${olContent}</ol>`;
47490
- case 'list_item':
47491
- const liContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
47492
- return `<li>${liContent}</li>`;
47493
- default:
47494
- // For unknown node types, try to render content
47495
- if (node.content) {
47496
- return node.content.map((n) => this.nodeToHtml(n, theme)).join('');
47497
- }
47498
- return '';
47499
- }
47500
- }
47501
- /**
47502
- * Converts ProseMirror JSON content to plain-text lines.
47503
- * Used by raster-safe SVG export, which cannot depend on HTML/foreignObject.
47504
- */
47505
- static prosemirrorToPlainTextLines(content) {
47506
- if (!content || !Array.isArray(content.content)) {
47507
- return [];
47508
- }
47509
- const plainText = content.content
47510
- .map((node) => this.nodeToPlainText(node))
47511
- .join('')
47512
- .replace(/\r\n/g, '\n')
47513
- .replace(/\n+$/, '');
47514
- if (!plainText) {
47515
- return [];
47516
- }
47517
- return plainText.split('\n');
47518
- }
47519
- /**
47520
- * Converts a ProseMirror node tree into plain text.
47521
- */
47522
- static nodeToPlainText(node) {
47523
- if (!node) {
47524
- return '';
47525
- }
47526
- switch (node.type) {
47527
- case 'text':
47528
- return node.text || '';
47529
- case 'hard_break':
47530
- return '\n';
47531
- case 'paragraph': {
47532
- const paragraph = Array.isArray(node.content)
47533
- ? node.content.map((child) => this.nodeToPlainText(child)).join('')
47534
- : '';
47535
- return `${paragraph}\n`;
47536
- }
47537
- case 'list_item': {
47538
- const item = Array.isArray(node.content)
47539
- ? node.content.map((child) => this.nodeToPlainText(child)).join('')
47540
- : '';
47541
- return `- ${item.trim()}\n`;
47542
- }
47543
- default:
47544
- return Array.isArray(node.content)
47545
- ? node.content.map((child) => this.nodeToPlainText(child)).join('')
47546
- : '';
47547
- }
47548
- }
47549
- /**
47550
- * Applies a ProseMirror mark to text.
47551
- * @param text - The text to wrap
47552
- * @param mark - The mark to apply
47553
- * @param theme - Theme for color resolution
47554
- * @returns Wrapped HTML string
47555
- */
47556
- static applyMark(text, mark, _theme) {
47557
- switch (mark.type) {
47558
- case 'bold':
47559
- case 'strong':
47560
- return `<strong>${text}</strong>`;
47561
- case 'italic':
47562
- case 'em':
47563
- return `<em>${text}</em>`;
47564
- case 'underline':
47565
- return `<u>${text}</u>`;
47566
- case 'strike':
47567
- return `<s>${text}</s>`;
47568
- case 'code':
47569
- return `<code>${text}</code>`;
47570
- case 'link':
47571
- const href = mark.attrs?.href || '#';
47572
- return `<a href="${this.escapeHtml(href)}">${text}</a>`;
47573
- default:
47574
- return text;
47575
- }
47576
- }
47577
- /**
47578
- * Escapes HTML special characters.
47579
- * @param str - String to escape
47580
- * @returns Escaped string
47581
- */
47582
- static escapeHtml(str) {
47583
- return str
47584
- .replace(/&/g, '&amp;')
47585
- .replace(/</g, '&lt;')
47586
- .replace(/>/g, '&gt;')
47587
- .replace(/"/g, '&quot;')
47588
- .replace(/'/g, '&#039;');
47589
- }
47590
- }
47591
-
47592
- function resolveUrl(url, baseUrl) {
47593
- // url is absolute already
47594
- if (url.match(/^[a-z]+:\/\//i)) {
47595
- return url;
47596
- }
47597
- // url is absolute already, without protocol
47598
- if (url.match(/^\/\//)) {
47599
- return window.location.protocol + url;
47600
- }
47601
- // dataURI, mailto:, tel:, etc.
47602
- if (url.match(/^[a-z]+:/i)) {
47603
- return url;
47604
- }
47605
- const doc = document.implementation.createHTMLDocument();
47606
- const base = doc.createElement('base');
47607
- const a = doc.createElement('a');
47608
- doc.head.appendChild(base);
47609
- doc.body.appendChild(a);
47610
- if (baseUrl) {
47611
- base.href = baseUrl;
47612
- }
47613
- a.href = url;
47614
- return a.href;
47615
- }
47616
- const uuid = (() => {
47617
- // generate uuid for className of pseudo elements.
47618
- // We should not use GUIDs, otherwise pseudo elements sometimes cannot be captured.
47619
- let counter = 0;
47620
- // ref: http://stackoverflow.com/a/6248722/2519373
47621
- const random = () =>
47622
- // eslint-disable-next-line no-bitwise
47623
- `0000${((Math.random() * 36 ** 4) << 0).toString(36)}`.slice(-4);
47624
- return () => {
47625
- counter += 1;
47626
- return `u${random()}${counter}`;
47627
- };
47628
- })();
47629
- function toArray(arrayLike) {
47630
- const arr = [];
47631
- for (let i = 0, l = arrayLike.length; i < l; i++) {
47632
- arr.push(arrayLike[i]);
47633
- }
47634
- return arr;
47635
- }
47636
- let styleProps = null;
47637
- function getStyleProperties(options = {}) {
47638
- if (styleProps) {
47639
- return styleProps;
47640
- }
47641
- if (options.includeStyleProperties) {
47642
- styleProps = options.includeStyleProperties;
47643
- return styleProps;
47644
- }
47645
- styleProps = toArray(window.getComputedStyle(document.documentElement));
47646
- return styleProps;
47647
- }
47648
- function px(node, styleProperty) {
47649
- const win = node.ownerDocument.defaultView || window;
47650
- const val = win.getComputedStyle(node).getPropertyValue(styleProperty);
47651
- return val ? parseFloat(val.replace('px', '')) : 0;
47652
- }
47653
- function getNodeWidth(node) {
47654
- const leftBorder = px(node, 'border-left-width');
47655
- const rightBorder = px(node, 'border-right-width');
47656
- return node.clientWidth + leftBorder + rightBorder;
47657
- }
47658
- function getNodeHeight(node) {
47659
- const topBorder = px(node, 'border-top-width');
47660
- const bottomBorder = px(node, 'border-bottom-width');
47661
- return node.clientHeight + topBorder + bottomBorder;
47662
- }
47663
- function getImageSize(targetNode, options = {}) {
47664
- const width = options.width || getNodeWidth(targetNode);
47665
- const height = options.height || getNodeHeight(targetNode);
47666
- return { width, height };
47667
- }
47668
- function getPixelRatio() {
47669
- let ratio;
47670
- let FINAL_PROCESS;
47671
- try {
47672
- FINAL_PROCESS = process;
47673
- }
47674
- catch (e) {
47675
- // pass
47676
- }
47677
- const val = FINAL_PROCESS && FINAL_PROCESS.env
47678
- ? FINAL_PROCESS.env.devicePixelRatio
47679
- : null;
47680
- if (val) {
47681
- ratio = parseInt(val, 10);
47682
- if (Number.isNaN(ratio)) {
47683
- ratio = 1;
47684
- }
47685
- }
47686
- return ratio || window.devicePixelRatio || 1;
47687
- }
47688
- // @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#maximum_canvas_size
47689
- const canvasDimensionLimit = 16384;
47690
- function checkCanvasDimensions(canvas) {
47691
- if (canvas.width > canvasDimensionLimit ||
47692
- canvas.height > canvasDimensionLimit) {
47693
- if (canvas.width > canvasDimensionLimit &&
47694
- canvas.height > canvasDimensionLimit) {
47695
- if (canvas.width > canvas.height) {
47696
- canvas.height *= canvasDimensionLimit / canvas.width;
47697
- canvas.width = canvasDimensionLimit;
47698
- }
47699
- else {
47700
- canvas.width *= canvasDimensionLimit / canvas.height;
47701
- canvas.height = canvasDimensionLimit;
47702
- }
47703
- }
47704
- else if (canvas.width > canvasDimensionLimit) {
47705
- canvas.height *= canvasDimensionLimit / canvas.width;
47706
- canvas.width = canvasDimensionLimit;
47707
- }
47708
- else {
47709
- canvas.width *= canvasDimensionLimit / canvas.height;
47710
- canvas.height = canvasDimensionLimit;
47711
- }
47712
- }
47713
- }
47714
- function createImage(url) {
47715
- return new Promise((resolve, reject) => {
47716
- const img = new Image();
47717
- img.onload = () => {
47718
- img.decode().then(() => {
47719
- requestAnimationFrame(() => resolve(img));
47720
- });
47721
- };
47722
- img.onerror = reject;
47723
- img.crossOrigin = 'anonymous';
47724
- img.decoding = 'async';
47725
- img.src = url;
47726
- });
47727
- }
47728
- async function svgToDataURL(svg) {
47729
- return Promise.resolve()
47730
- .then(() => new XMLSerializer().serializeToString(svg))
47731
- .then(encodeURIComponent)
47732
- .then((html) => `data:image/svg+xml;charset=utf-8,${html}`);
47733
- }
47734
- async function nodeToDataURL(node, width, height) {
47735
- const xmlns = 'http://www.w3.org/2000/svg';
47736
- const svg = document.createElementNS(xmlns, 'svg');
47737
- const foreignObject = document.createElementNS(xmlns, 'foreignObject');
47738
- svg.setAttribute('width', `${width}`);
47739
- svg.setAttribute('height', `${height}`);
47740
- svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
47741
- foreignObject.setAttribute('width', '100%');
47742
- foreignObject.setAttribute('height', '100%');
47743
- foreignObject.setAttribute('x', '0');
47744
- foreignObject.setAttribute('y', '0');
47745
- foreignObject.setAttribute('externalResourcesRequired', 'true');
47746
- svg.appendChild(foreignObject);
47747
- foreignObject.appendChild(node);
47748
- return svgToDataURL(svg);
47749
- }
47750
- const isInstanceOfElement = (node, instance) => {
47751
- if (node instanceof instance)
47752
- return true;
47753
- const nodePrototype = Object.getPrototypeOf(node);
47754
- if (nodePrototype === null)
47755
- return false;
47756
- return (nodePrototype.constructor.name === instance.name ||
47757
- isInstanceOfElement(nodePrototype, instance));
47758
- };
47759
-
47760
- function formatCSSText(style) {
47761
- const content = style.getPropertyValue('content');
47762
- return `${style.cssText} content: '${content.replace(/'|"/g, '')}';`;
47763
- }
47764
- function formatCSSProperties(style, options) {
47765
- return getStyleProperties(options)
47766
- .map((name) => {
47767
- const value = style.getPropertyValue(name);
47768
- const priority = style.getPropertyPriority(name);
47769
- return `${name}: ${value}${priority ? ' !important' : ''};`;
47770
- })
47771
- .join(' ');
47772
- }
47773
- function getPseudoElementStyle(className, pseudo, style, options) {
47774
- const selector = `.${className}:${pseudo}`;
47775
- const cssText = style.cssText
47776
- ? formatCSSText(style)
47777
- : formatCSSProperties(style, options);
47778
- return document.createTextNode(`${selector}{${cssText}}`);
47779
- }
47780
- function clonePseudoElement(nativeNode, clonedNode, pseudo, options) {
47781
- const style = window.getComputedStyle(nativeNode, pseudo);
47782
- const content = style.getPropertyValue('content');
47783
- if (content === '' || content === 'none') {
47784
- return;
47785
- }
47786
- const className = uuid();
47787
- try {
47788
- clonedNode.className = `${clonedNode.className} ${className}`;
47789
- }
47790
- catch (err) {
47791
- return;
47792
- }
47793
- const styleElement = document.createElement('style');
47794
- styleElement.appendChild(getPseudoElementStyle(className, pseudo, style, options));
47795
- clonedNode.appendChild(styleElement);
47796
- }
47797
- function clonePseudoElements(nativeNode, clonedNode, options) {
47798
- clonePseudoElement(nativeNode, clonedNode, ':before', options);
47799
- clonePseudoElement(nativeNode, clonedNode, ':after', options);
47800
- }
47801
-
47802
- const WOFF = 'application/font-woff';
47803
- const JPEG = 'image/jpeg';
47804
- const mimes = {
47805
- woff: WOFF,
47806
- woff2: WOFF,
47807
- ttf: 'application/font-truetype',
47808
- eot: 'application/vnd.ms-fontobject',
47809
- png: 'image/png',
47810
- jpg: JPEG,
47811
- jpeg: JPEG,
47812
- gif: 'image/gif',
47813
- tiff: 'image/tiff',
47814
- svg: 'image/svg+xml',
47815
- webp: 'image/webp',
47816
- };
47817
- function getExtension(url) {
47818
- const match = /\.([^./]*?)$/g.exec(url);
47819
- return match ? match[1] : '';
47820
- }
47821
- function getMimeType(url) {
47822
- const extension = getExtension(url).toLowerCase();
47823
- return mimes[extension] || '';
47824
- }
47825
-
47826
- function getContentFromDataUrl(dataURL) {
47827
- return dataURL.split(/,/)[1];
47828
- }
47829
- function isDataUrl(url) {
47830
- return url.search(/^(data:)/) !== -1;
47831
- }
47832
- function makeDataUrl(content, mimeType) {
47833
- return `data:${mimeType};base64,${content}`;
47834
- }
47835
- async function fetchAsDataURL(url, init, process) {
47836
- const res = await fetch(url, init);
47837
- if (res.status === 404) {
47838
- throw new Error(`Resource "${res.url}" not found`);
47839
- }
47840
- const blob = await res.blob();
47841
- return new Promise((resolve, reject) => {
47842
- const reader = new FileReader();
47843
- reader.onerror = reject;
47844
- reader.onloadend = () => {
47845
- try {
47846
- resolve(process({ res, result: reader.result }));
47847
- }
47848
- catch (error) {
47849
- reject(error);
47850
- }
47851
- };
47852
- reader.readAsDataURL(blob);
47853
- });
47854
- }
47855
- const cache = {};
47856
- function getCacheKey(url, contentType, includeQueryParams) {
47857
- let key = url.replace(/\?.*/, '');
47858
- if (includeQueryParams) {
47859
- key = url;
47860
- }
47861
- // font resource
47862
- if (/ttf|otf|eot|woff2?/i.test(key)) {
47863
- key = key.replace(/.*\//, '');
47864
- }
47865
- return contentType ? `[${contentType}]${key}` : key;
47866
- }
47867
- async function resourceToDataURL(resourceUrl, contentType, options) {
47868
- const cacheKey = getCacheKey(resourceUrl, contentType, options.includeQueryParams);
47869
- if (cache[cacheKey] != null) {
47870
- return cache[cacheKey];
47871
- }
47872
- // ref: https://developer.mozilla.org/en/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest#Bypassing_the_cache
47873
- if (options.cacheBust) {
47874
- // eslint-disable-next-line no-param-reassign
47875
- resourceUrl += (/\?/.test(resourceUrl) ? '&' : '?') + new Date().getTime();
47876
- }
47877
- let dataURL;
47878
- try {
47879
- const content = await fetchAsDataURL(resourceUrl, options.fetchRequestInit, ({ res, result }) => {
47880
- if (!contentType) {
47881
- // eslint-disable-next-line no-param-reassign
47882
- contentType = res.headers.get('Content-Type') || '';
47883
- }
47884
- return getContentFromDataUrl(result);
47885
- });
47886
- dataURL = makeDataUrl(content, contentType);
47887
- }
47888
- catch (error) {
47889
- dataURL = options.imagePlaceholder || '';
47890
- let msg = `Failed to fetch resource: ${resourceUrl}`;
47891
- if (error) {
47892
- msg = typeof error === 'string' ? error : error.message;
47893
- }
47894
- if (msg) {
47895
- console.warn(msg);
47896
- }
47897
47329
  }
47898
47330
  cache[cacheKey] = dataURL;
47899
47331
  return dataURL;
@@ -48478,104 +47910,756 @@ class KritzelWheelHelper {
48478
47910
  if (!scrollContainer) {
48479
47911
  return false;
48480
47912
  }
48481
- return this.canElementConsumeWheel(scrollContainer, ev.deltaX, ev.deltaY);
48482
- }
48483
- static getScrollableContainerFromEventPath(path, customElement) {
48484
- const potentialRoots = [];
48485
- if (customElement.element instanceof HTMLElement) {
48486
- potentialRoots.push(customElement.element);
47913
+ return this.canElementConsumeWheel(scrollContainer, ev.deltaX, ev.deltaY);
47914
+ }
47915
+ static getScrollableContainerFromEventPath(path, customElement) {
47916
+ const potentialRoots = [];
47917
+ if (customElement.element instanceof HTMLElement) {
47918
+ potentialRoots.push(customElement.element);
47919
+ }
47920
+ if (customElement.elementRef instanceof HTMLElement) {
47921
+ potentialRoots.push(customElement.elementRef);
47922
+ }
47923
+ const legacyElementRef = customElement.elmeentRef;
47924
+ if (legacyElementRef instanceof HTMLElement) {
47925
+ potentialRoots.push(legacyElementRef);
47926
+ }
47927
+ for (const root of potentialRoots) {
47928
+ const rootIndex = path.findIndex(entry => entry === root);
47929
+ if (rootIndex === -1) {
47930
+ continue;
47931
+ }
47932
+ for (const entry of path.slice(0, rootIndex + 1)) {
47933
+ if (!(entry instanceof HTMLElement)) {
47934
+ continue;
47935
+ }
47936
+ if (this.isScrollableElement(entry)) {
47937
+ return entry;
47938
+ }
47939
+ }
47940
+ }
47941
+ // Fallback for synthetic/custom wheel events where composedPath is absent
47942
+ // or does not include the custom-element subtree.
47943
+ for (const root of potentialRoots) {
47944
+ if (this.isScrollableElement(root)) {
47945
+ return root;
47946
+ }
47947
+ const firstChild = root.firstElementChild;
47948
+ if (firstChild instanceof HTMLElement && this.isScrollableElement(firstChild)) {
47949
+ return firstChild;
47950
+ }
47951
+ }
47952
+ return null;
47953
+ }
47954
+ static isScrollableElement(element) {
47955
+ const canScrollVertically = element.scrollHeight - element.clientHeight > 0;
47956
+ const canScrollHorizontally = element.scrollWidth - element.clientWidth > 0;
47957
+ if (!canScrollVertically && !canScrollHorizontally) {
47958
+ return false;
47959
+ }
47960
+ const computedStyle = window.getComputedStyle(element);
47961
+ const overflowX = computedStyle.overflowX;
47962
+ const overflowY = computedStyle.overflowY;
47963
+ const allowsVerticalScroll = overflowY !== 'visible' && overflowY !== 'clip';
47964
+ const allowsHorizontalScroll = overflowX !== 'visible' && overflowX !== 'clip';
47965
+ return (canScrollVertically && allowsVerticalScroll) || (canScrollHorizontally && allowsHorizontalScroll);
47966
+ }
47967
+ static canElementConsumeWheel(element, deltaX, deltaY) {
47968
+ const canConsumeVertical = this.canElementConsumeVerticalWheel(element, deltaY);
47969
+ const canConsumeHorizontal = this.canElementConsumeHorizontalWheel(element, deltaX);
47970
+ // Keep wheel events inside the custom element whenever at least one
47971
+ // requested axis can still be consumed by its scroll container.
47972
+ return canConsumeVertical || canConsumeHorizontal;
47973
+ }
47974
+ static canElementConsumeVerticalWheel(element, deltaY) {
47975
+ if (deltaY === 0) {
47976
+ return false;
47977
+ }
47978
+ const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
47979
+ if (maxScrollTop === 0) {
47980
+ return false;
47981
+ }
47982
+ const epsilon = 1;
47983
+ const scrollingDown = deltaY > 0;
47984
+ const scrollingUp = deltaY < 0;
47985
+ if (scrollingDown) {
47986
+ return element.scrollTop < maxScrollTop - epsilon;
47987
+ }
47988
+ if (scrollingUp) {
47989
+ return element.scrollTop > epsilon;
47990
+ }
47991
+ return false;
47992
+ }
47993
+ static canElementConsumeHorizontalWheel(element, deltaX) {
47994
+ if (deltaX === 0) {
47995
+ return false;
47996
+ }
47997
+ const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth);
47998
+ if (maxScrollLeft === 0) {
47999
+ return false;
48000
+ }
48001
+ const epsilon = 1;
48002
+ const scrollingRight = deltaX > 0;
48003
+ const scrollingLeft = deltaX < 0;
48004
+ if (scrollingRight) {
48005
+ return element.scrollLeft < maxScrollLeft - epsilon;
48006
+ }
48007
+ if (scrollingLeft) {
48008
+ return element.scrollLeft > epsilon;
48009
+ }
48010
+ return false;
48011
+ }
48012
+ }
48013
+
48014
+ /**
48015
+ * Helper class for exporting Kritzel objects to SVG format.
48016
+ * Generates clean, standards-compliant SVG markup from canvas objects.
48017
+ */
48018
+ class KritzelSvgExportHelper {
48019
+ /**
48020
+ * Calculates the combined bounding box that encompasses all objects.
48021
+ * Uses rotatedBoundingBox to account for object rotation.
48022
+ * @param objects - Array of objects to calculate bounds for
48023
+ * @returns Combined bounding box, or null if no objects provided
48024
+ */
48025
+ static calculateCombinedBounds(objects) {
48026
+ if (!objects || objects.length === 0) {
48027
+ return null;
48028
+ }
48029
+ let minX = Infinity;
48030
+ let minY = Infinity;
48031
+ let maxX = -Infinity;
48032
+ let maxY = -Infinity;
48033
+ for (const obj of objects) {
48034
+ const bbox = obj.rotatedBoundingBox;
48035
+ minX = Math.min(minX, bbox.x);
48036
+ minY = Math.min(minY, bbox.y);
48037
+ maxX = Math.max(maxX, bbox.x + bbox.width);
48038
+ maxY = Math.max(maxY, bbox.y + bbox.height);
48039
+ }
48040
+ return {
48041
+ x: minX,
48042
+ y: minY,
48043
+ z: 1,
48044
+ width: maxX - minX,
48045
+ height: maxY - minY,
48046
+ };
48047
+ }
48048
+ /**
48049
+ * Generates a complete SVG document string from the given objects.
48050
+ * @param objects - Objects to export
48051
+ * @param options - Export options
48052
+ * @returns SVG document string
48053
+ */
48054
+ static generateSvg(objects, options = {}) {
48055
+ const { theme, padding = 0, includeXmlDeclaration = true, rasterSafeForCanvas = false, imageDataUrls } = options;
48056
+ if (!objects || objects.length === 0) {
48057
+ return '';
48058
+ }
48059
+ // Flatten groups to get all objects including nested children
48060
+ const allObjects = this.flattenObjects(objects);
48061
+ const bounds = this.calculateCombinedBounds(allObjects);
48062
+ if (!bounds) {
48063
+ return '';
48064
+ }
48065
+ // Add padding to bounds
48066
+ const viewBoxX = bounds.x - padding;
48067
+ const viewBoxY = bounds.y - padding;
48068
+ const viewBoxWidth = bounds.width + padding * 2;
48069
+ const viewBoxHeight = bounds.height + padding * 2;
48070
+ // Collect all defs (markers, patterns, etc.)
48071
+ const defs = this.collectDefs(allObjects, theme);
48072
+ // Generate SVG elements for each object
48073
+ const elements = objects
48074
+ .sort((a, b) => a.zIndex - b.zIndex)
48075
+ .map(obj => this.objectToSvgElement(obj, theme, imageDataUrls, rasterSafeForCanvas))
48076
+ .filter(Boolean)
48077
+ .join('\n ');
48078
+ const xmlDecl = includeXmlDeclaration ? '<?xml version="1.0" encoding="UTF-8"?>\n' : '';
48079
+ const defsContent = defs.length > 0 ? `\n <defs>\n ${defs.join('\n ')}\n </defs>` : '';
48080
+ return `${xmlDecl}<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBoxX} ${viewBoxY} ${viewBoxWidth} ${viewBoxHeight}" width="${viewBoxWidth}" height="${viewBoxHeight}">${defsContent}
48081
+ ${elements}
48082
+ </svg>`;
48083
+ }
48084
+ /**
48085
+ * Flattens a list of objects, expanding groups into their children recursively.
48086
+ * @param objects - Objects to flatten
48087
+ * @returns Flat array of all objects including group children
48088
+ */
48089
+ static flattenObjects(objects) {
48090
+ const result = [];
48091
+ for (const obj of objects) {
48092
+ if (KritzelClassHelper.isInstanceOf(obj, 'KritzelGroup')) {
48093
+ result.push(...this.flattenObjects(obj.children));
48094
+ }
48095
+ else {
48096
+ result.push(obj);
48097
+ }
48098
+ }
48099
+ return result;
48100
+ }
48101
+ /**
48102
+ * Resolves every image referenced by the given objects (including those
48103
+ * nested inside groups) to an inlined `data:` URL, keyed by object id.
48104
+ *
48105
+ * This is required for export flows that rasterize the SVG (e.g. PNG
48106
+ * export): an SVG loaded into an `<img>` element will NOT load external
48107
+ * or `blob:` resources, so a `<image href="blob:...">` renders as a
48108
+ * broken-image placeholder. Embedding the bytes as a base64 `data:` URL
48109
+ * makes the image self-contained and survive rasterization.
48110
+ *
48111
+ * Resolution is best-effort: images that fail to resolve are simply
48112
+ * omitted from the map, and the SVG falls back to whatever `href` the
48113
+ * synchronous path would have produced.
48114
+ * @param objects - Objects to inline images for
48115
+ * @param assetResolver - Resolver used to fetch asset bytes; optional
48116
+ * @returns Map of image object id -> inlined `data:` URL
48117
+ */
48118
+ static async resolveImageDataUrls(objects, assetResolver) {
48119
+ const dataUrls = new Map();
48120
+ if (!objects || objects.length === 0) {
48121
+ return dataUrls;
48122
+ }
48123
+ const images = this.flattenObjects(objects).filter((obj) => KritzelClassHelper.isInstanceOf(obj, 'KritzelImage'));
48124
+ await Promise.all(images.map(async (image) => {
48125
+ // Legacy inline data URL is already embeddable as-is.
48126
+ if (image.src?.startsWith('data:')) {
48127
+ dataUrls.set(image.id, image.src);
48128
+ return;
48129
+ }
48130
+ // A resolvedSrc that is itself a data URL can be embedded directly.
48131
+ if (image.resolvedSrc?.startsWith('data:')) {
48132
+ dataUrls.set(image.id, image.resolvedSrc);
48133
+ return;
48134
+ }
48135
+ if (!image.assetId || !assetResolver) {
48136
+ return;
48137
+ }
48138
+ try {
48139
+ const blob = await assetResolver.fetchBlob(image.assetId);
48140
+ dataUrls.set(image.id, await this.blobToDataUrl(blob));
48141
+ }
48142
+ catch (err) {
48143
+ console.warn(`[KritzelSvgExportHelper] Failed to inline image asset ${image.assetId} for export:`, err);
48144
+ }
48145
+ }));
48146
+ return dataUrls;
48147
+ }
48148
+ /**
48149
+ * Reads a Blob into a base64 `data:` URL.
48150
+ * @param blob - The blob to encode
48151
+ * @returns Promise resolving to the data URL string
48152
+ */
48153
+ static async blobToDataUrl(blob) {
48154
+ // Prefer FileReader in browser-like runtimes for efficiency.
48155
+ const hasFileReader = typeof FileReader !== 'undefined';
48156
+ if (hasFileReader) {
48157
+ try {
48158
+ return await new Promise((resolve, reject) => {
48159
+ const reader = new FileReader();
48160
+ reader.onload = () => {
48161
+ if (typeof reader.result === 'string') {
48162
+ resolve(reader.result);
48163
+ return;
48164
+ }
48165
+ reject(new Error('FileReader result is not a string'));
48166
+ };
48167
+ reader.onerror = () => reject(reader.error ?? new Error('Failed to read blob as data URL'));
48168
+ reader.readAsDataURL(blob);
48169
+ });
48170
+ }
48171
+ catch {
48172
+ // Fall through to the ArrayBuffer-based encoder.
48173
+ }
48174
+ }
48175
+ const bytes = new Uint8Array(await blob.arrayBuffer());
48176
+ const mimeType = blob.type || 'application/octet-stream';
48177
+ return `data:${mimeType};base64,${this.uint8ToBase64(bytes)}`;
48178
+ }
48179
+ /**
48180
+ * Encodes a byte array to base64 across browser and Node-like runtimes.
48181
+ */
48182
+ static uint8ToBase64(bytes) {
48183
+ if (typeof Buffer !== 'undefined') {
48184
+ return Buffer.from(bytes).toString('base64');
48185
+ }
48186
+ let binary = '';
48187
+ const chunkSize = 0x8000;
48188
+ for (let i = 0; i < bytes.length; i += chunkSize) {
48189
+ const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length));
48190
+ binary += String.fromCharCode(...chunk);
48191
+ }
48192
+ if (typeof btoa === 'function') {
48193
+ return btoa(binary);
48194
+ }
48195
+ throw new Error('No base64 encoder available in this runtime');
48196
+ }
48197
+ /**
48198
+ * Collects SVG defs (markers, patterns, etc.) needed by the objects.
48199
+ * @param objects - Objects to collect defs for
48200
+ * @param theme - Theme for color resolution
48201
+ * @returns Array of def element strings
48202
+ */
48203
+ static collectDefs(objects, theme) {
48204
+ const defs = [];
48205
+ for (const obj of objects) {
48206
+ if (KritzelClassHelper.isInstanceOf(obj, 'KritzelLine')) {
48207
+ if (obj.hasStartArrow) {
48208
+ defs.push(this.generateArrowMarker(obj, 'start', theme));
48209
+ }
48210
+ if (obj.hasEndArrow) {
48211
+ defs.push(this.generateArrowMarker(obj, 'end', theme));
48212
+ }
48213
+ }
48214
+ }
48215
+ return defs;
48216
+ }
48217
+ /**
48218
+ * Generates an SVG marker element for line arrows.
48219
+ * @param line - The line object
48220
+ * @param end - Which end of the line ('start' or 'end')
48221
+ * @param theme - Theme for color resolution
48222
+ * @returns SVG marker element string
48223
+ */
48224
+ static generateArrowMarker(line, end, _theme) {
48225
+ const markerId = end === 'start' ? line.startMarkerId : line.endMarkerId;
48226
+ const size = line.getArrowSize(end);
48227
+ const fill = line.getArrowFill(end);
48228
+ const style = end === 'start' ? line.arrows?.start?.style : line.arrows?.end?.style;
48229
+ const path = line.getArrowPath(style);
48230
+ const orient = end === 'start' ? 'auto-start-reverse' : 'auto';
48231
+ const refX = 0;
48232
+ const refY = size / 2;
48233
+ return `<marker id="${markerId}" markerWidth="${size}" markerHeight="${size}" refX="${refX}" refY="${refY}" orient="${orient}" markerUnits="userSpaceOnUse">
48234
+ <path d="${path}" fill="${fill}" transform="scale(${size / 10})"/>
48235
+ </marker>`;
48236
+ }
48237
+ /**
48238
+ * Converts a Kritzel object to its SVG element representation.
48239
+ * @param object - The object to convert
48240
+ * @param theme - Theme for color resolution
48241
+ * @param imageDataUrls - Optional map of image id -> inlined data URL
48242
+ * @returns SVG element string, or empty string if object type is not supported
48243
+ */
48244
+ static objectToSvgElement(object, theme, imageDataUrls, rasterSafeForCanvas = false) {
48245
+ if (KritzelClassHelper.isInstanceOf(object, 'KritzelPath')) {
48246
+ return this.pathToSvg(object, theme);
48247
+ }
48248
+ if (KritzelClassHelper.isInstanceOf(object, 'KritzelLine')) {
48249
+ return this.lineToSvg(object, theme);
48250
+ }
48251
+ if (KritzelClassHelper.isInstanceOf(object, 'KritzelShape')) {
48252
+ return this.shapeToSvg(object, theme);
48253
+ }
48254
+ if (KritzelClassHelper.isInstanceOf(object, 'KritzelText')) {
48255
+ return this.textToSvg(object, theme, rasterSafeForCanvas);
48487
48256
  }
48488
- if (customElement.elementRef instanceof HTMLElement) {
48489
- potentialRoots.push(customElement.elementRef);
48257
+ if (KritzelClassHelper.isInstanceOf(object, 'KritzelImage')) {
48258
+ return this.imageToSvg(object, imageDataUrls);
48490
48259
  }
48491
- const legacyElementRef = customElement.elmeentRef;
48492
- if (legacyElementRef instanceof HTMLElement) {
48493
- potentialRoots.push(legacyElementRef);
48260
+ if (KritzelClassHelper.isInstanceOf(object, 'KritzelCustomElement')) {
48261
+ return this.customElementToSvg(object, rasterSafeForCanvas);
48494
48262
  }
48495
- for (const root of potentialRoots) {
48496
- const rootIndex = path.findIndex(entry => entry === root);
48497
- if (rootIndex === -1) {
48498
- continue;
48499
- }
48500
- for (const entry of path.slice(0, rootIndex + 1)) {
48501
- if (!(entry instanceof HTMLElement)) {
48502
- continue;
48503
- }
48504
- if (this.isScrollableElement(entry)) {
48505
- return entry;
48506
- }
48507
- }
48263
+ if (KritzelClassHelper.isInstanceOf(object, 'KritzelGroup')) {
48264
+ return this.groupToSvg(object, theme, imageDataUrls, rasterSafeForCanvas);
48508
48265
  }
48509
- // Fallback for synthetic/custom wheel events where composedPath is absent
48510
- // or does not include the custom-element subtree.
48511
- for (const root of potentialRoots) {
48512
- if (this.isScrollableElement(root)) {
48513
- return root;
48514
- }
48515
- const firstChild = root.firstElementChild;
48516
- if (firstChild instanceof HTMLElement && this.isScrollableElement(firstChild)) {
48517
- return firstChild;
48266
+ return '';
48267
+ }
48268
+ /**
48269
+ * Converts a KritzelPath to SVG.
48270
+ * @param path - The path object
48271
+ * @param theme - Theme for color resolution
48272
+ * @returns SVG element string
48273
+ */
48274
+ static pathToSvg(path, theme) {
48275
+ const transform = this.buildTransform(path);
48276
+ const fill = KritzelColorHelper.resolveThemeColor(path.fill, theme);
48277
+ const stroke = KritzelColorHelper.resolveThemeColor(path.stroke, theme);
48278
+ const opacity = path.opacity !== 1 ? ` opacity="${path.opacity}"` : '';
48279
+ const scale = this.getObjectScale(path);
48280
+ // Path needs to be wrapped in a g with translation since path.d uses local coordinates
48281
+ return `<g transform="${transform}"${opacity}>
48282
+ <svg viewBox="${path.viewBox}" width="${path.totalWidth / scale}" height="${path.totalHeight / scale}" overflow="visible">
48283
+ <path d="${path.d}" fill="${fill || 'none'}" stroke="${stroke || 'none'}"${path.strokeWidth ? ` stroke-width="${path.strokeWidth}"` : ''}/>
48284
+ </svg>
48285
+ </g>`;
48286
+ }
48287
+ /**
48288
+ * Converts a KritzelLine to SVG.
48289
+ * @param line - The line object
48290
+ * @param theme - Theme for color resolution
48291
+ * @returns SVG element string
48292
+ */
48293
+ static lineToSvg(line, theme) {
48294
+ const transform = this.buildTransform(line);
48295
+ const stroke = KritzelColorHelper.resolveThemeColor(line.stroke, theme);
48296
+ const opacity = line.opacity !== 1 ? ` opacity="${line.opacity}"` : '';
48297
+ const scale = this.getObjectScale(line);
48298
+ const markerStart = line.hasStartArrow ? ` marker-start="url(#${line.startMarkerId})"` : '';
48299
+ const markerEnd = line.hasEndArrow ? ` marker-end="url(#${line.endMarkerId})"` : '';
48300
+ return `<g transform="${transform}"${opacity}>
48301
+ <svg viewBox="${line.viewBox}" width="${line.totalWidth / scale}" height="${line.totalHeight / scale}" overflow="visible">
48302
+ <path d="${line.d}" fill="none" stroke="${stroke}" stroke-width="${line.strokeWidth}" stroke-linecap="round"${markerStart}${markerEnd}/>
48303
+ </svg>
48304
+ </g>`;
48305
+ }
48306
+ /**
48307
+ * Converts a KritzelShape to SVG.
48308
+ * @param shape - The shape object
48309
+ * @param theme - Theme for color resolution
48310
+ * @returns SVG element string
48311
+ */
48312
+ static shapeToSvg(shape, theme) {
48313
+ const transform = this.buildTransform(shape);
48314
+ const fill = KritzelColorHelper.resolveThemeColor(shape.fillColor, theme);
48315
+ const stroke = KritzelColorHelper.resolveThemeColor(shape.strokeColor, theme);
48316
+ const opacity = shape.opacity !== 1 ? ` opacity="${shape.opacity}"` : '';
48317
+ const scale = this.getObjectScale(shape);
48318
+ // Get the SVG path for the shape
48319
+ const pathD = shape.getSvgPath();
48320
+ // Handle text content if present
48321
+ let textContent = '';
48322
+ if (shape.editor && !shape.editor.state.doc.textContent.trim()) ;
48323
+ else if (shape.content) {
48324
+ const textHtml = this.prosemirrorToHtml(shape.content, shape, theme);
48325
+ if (textHtml) {
48326
+ const fontColor = KritzelColorHelper.resolveThemeColor(shape.fontColor, theme);
48327
+ textContent = `
48328
+ <foreignObject x="${shape.x}" y="${shape.y}" width="${shape.width}" height="${shape.height}">
48329
+ <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;">
48330
+ ${textHtml}
48331
+ </div>
48332
+ </foreignObject>`;
48518
48333
  }
48519
48334
  }
48520
- return null;
48335
+ return `<g transform="${transform}"${opacity}>
48336
+ <svg viewBox="${shape.viewBox}" width="${shape.totalWidth / scale}" height="${shape.totalHeight / scale}" overflow="visible" preserveAspectRatio="none">
48337
+ <path d="${pathD}" fill="${fill || 'transparent'}" stroke="${stroke}" stroke-width="${shape.strokeWidth}"/>${textContent}
48338
+ </svg>
48339
+ </g>`;
48521
48340
  }
48522
- static isScrollableElement(element) {
48523
- const canScrollVertically = element.scrollHeight - element.clientHeight > 0;
48524
- const canScrollHorizontally = element.scrollWidth - element.clientWidth > 0;
48525
- if (!canScrollVertically && !canScrollHorizontally) {
48526
- return false;
48341
+ /**
48342
+ * Converts a KritzelText to SVG using foreignObject for rich text support.
48343
+ * @param text - The text object
48344
+ * @param theme - Theme for color resolution
48345
+ * @returns SVG element string
48346
+ */
48347
+ static textToSvg(text, theme, rasterSafeForCanvas = false) {
48348
+ if (rasterSafeForCanvas) {
48349
+ return this.textToSvgRasterSafe(text, theme);
48527
48350
  }
48528
- const computedStyle = window.getComputedStyle(element);
48529
- const overflowX = computedStyle.overflowX;
48530
- const overflowY = computedStyle.overflowY;
48531
- const allowsVerticalScroll = overflowY !== 'visible' && overflowY !== 'clip';
48532
- const allowsHorizontalScroll = overflowX !== 'visible' && overflowX !== 'clip';
48533
- return (canScrollVertically && allowsVerticalScroll) || (canScrollHorizontally && allowsHorizontalScroll);
48351
+ const transform = this.buildTransform(text);
48352
+ const opacity = text.opacity !== 1 ? ` opacity="${text.opacity}"` : '';
48353
+ const fontColor = KritzelColorHelper.resolveThemeColor(text.fontColor, theme);
48354
+ const bgColor = KritzelColorHelper.resolveThemeColor(text.backgroundColor, theme);
48355
+ const scale = this.getObjectScale(text);
48356
+ // Convert ProseMirror content to HTML
48357
+ const htmlContent = this.prosemirrorToHtml(text.content, text, theme);
48358
+ // Calculate dimensions in SVG world units (world units = CSS px in a 1:1 SVG).
48359
+ const width = text.totalWidth / scale;
48360
+ const height = text.totalHeight / scale;
48361
+ // Convert the font size from pt to SVG world units (px equivalent).
48362
+ // text.fontSize is stored in CSS pt; 1 pt = 4/3 px at 96 dpi.
48363
+ // We also bake in scaleFactor (user resize) and divide by the object scale so
48364
+ // that the rendered font aligns with the world-unit dimensions above.
48365
+ // The CSS transform: scale(scaleFactor) is removed from the div because the
48366
+ // adjusted font-size already accounts for it – keeping both would double-scale
48367
+ // the content and overflow the foreignObject.
48368
+ const svgFontSizePx = (text.fontSize * (4 / 3) * (text.scaleFactor || 1) / scale).toFixed(4);
48369
+ return `<g transform="${transform}"${opacity}>
48370
+ <foreignObject x="0" y="0" width="${width}" height="${height}" overflow="visible">
48371
+ <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>
48372
+ </foreignObject>
48373
+ </g>`;
48534
48374
  }
48535
- static canElementConsumeWheel(element, deltaX, deltaY) {
48536
- const canConsumeVertical = this.canElementConsumeVerticalWheel(element, deltaY);
48537
- const canConsumeHorizontal = this.canElementConsumeHorizontalWheel(element, deltaX);
48538
- // Keep wheel events inside the custom element whenever at least one
48539
- // requested axis can still be consumed by its scroll container.
48540
- return canConsumeVertical || canConsumeHorizontal;
48375
+ /**
48376
+ * Converts a KritzelText to SVG using pure SVG text primitives.
48377
+ * This path avoids `<foreignObject>`, which taints canvases when the
48378
+ * generated SVG is rasterized through `<img>` and `drawImage`.
48379
+ */
48380
+ static textToSvgRasterSafe(text, theme) {
48381
+ const transform = this.buildTransform(text);
48382
+ const opacity = text.opacity !== 1 ? ` opacity="${text.opacity}"` : '';
48383
+ const fontColor = KritzelColorHelper.resolveThemeColor(text.fontColor, theme);
48384
+ const bgColor = KritzelColorHelper.resolveThemeColor(text.backgroundColor, theme);
48385
+ const scale = this.getObjectScale(text);
48386
+ const width = text.totalWidth / scale;
48387
+ const height = text.totalHeight / scale;
48388
+ const lines = this.prosemirrorToPlainTextLines(text.content);
48389
+ const resolvedLines = lines.length > 0 ? lines : [''];
48390
+ // Convert the font size from pt to SVG world units (px equivalent).
48391
+ // text.fontSize is stored in CSS pt; 1 pt = 4/3 px at 96 dpi.
48392
+ // We also bake in scaleFactor (user resize) and divide by the object scale so
48393
+ // that the rendered lines align with the world-unit bounding box dimensions above.
48394
+ const resolvedFontSizePx = Math.max(1, text.fontSize * (4 / 3) * (text.scaleFactor || 1) / scale);
48395
+ const lineHeight = resolvedFontSizePx * 1.2;
48396
+ const startY = resolvedFontSizePx;
48397
+ const bgRect = bgColor && bgColor !== 'transparent'
48398
+ ? `<rect x="0" y="0" width="${width}" height="${height}" fill="${bgColor}"/>`
48399
+ : '';
48400
+ const tspans = resolvedLines
48401
+ .map((line, index) => {
48402
+ const y = startY + index * lineHeight;
48403
+ return `<tspan x="0" y="${y}" xml:space="preserve">${this.escapeHtml(line)}</tspan>`;
48404
+ })
48405
+ .join('');
48406
+ return `<g transform="${transform}"${opacity}>
48407
+ ${bgRect}
48408
+ <text font-family="${this.escapeHtml(text.fontFamily)}" font-size="${resolvedFontSizePx}" fill="${fontColor}">${tspans}</text>
48409
+ </g>`;
48541
48410
  }
48542
- static canElementConsumeVerticalWheel(element, deltaY) {
48543
- if (deltaY === 0) {
48544
- return false;
48411
+ /**
48412
+ * Converts a KritzelImage to SVG.
48413
+ *
48414
+ * Prefers an inlined `data:` URL from `imageDataUrls` (keyed by object
48415
+ * id) when provided, so the embedded bytes survive rasterization to
48416
+ * PNG. Falls back to `resolvedSrc` (populated by the renderer via the
48417
+ * asset resolver) and finally the legacy inline `src` field for
48418
+ * documents persisted before the asset layer existed.
48419
+ *
48420
+ * Note: a `blob:` `resolvedSrc` renders correctly on-canvas but becomes
48421
+ * a broken-image placeholder once the SVG is loaded into an `<img>` and
48422
+ * drawn to a canvas. Callers that rasterize the SVG must pass
48423
+ * `imageDataUrls` (see {@link KritzelSvgExportHelper.resolveImageDataUrls}).
48424
+ * @param image - The image object
48425
+ * @param imageDataUrls - Optional map of image id -> inlined data URL
48426
+ * @returns SVG element string
48427
+ */
48428
+ static imageToSvg(image, imageDataUrls) {
48429
+ const transform = this.buildTransform(image);
48430
+ const opacity = image.opacity !== 1 ? ` opacity="${image.opacity}"` : '';
48431
+ const scale = this.getObjectScale(image);
48432
+ // Calculate dimensions
48433
+ const width = image.totalWidth / scale;
48434
+ const height = image.totalHeight / scale;
48435
+ const href = imageDataUrls?.get(image.id) || image.resolvedSrc || image.src || '';
48436
+ return `<g transform="${transform}"${opacity}>
48437
+ <image href="${href}" x="0" y="0" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet"/>
48438
+ </g>`;
48439
+ }
48440
+ /**
48441
+ * Converts a KritzelCustomElement to SVG.
48442
+ * SVG mode: embeds HTML in foreignObject (best-effort).
48443
+ * PNG/raster mode: renders placeholder rect with warning.
48444
+ * @param obj - The custom element object
48445
+ * @param rasterSafeForCanvas - If true, render placeholder (for PNG export)
48446
+ * @returns SVG element string
48447
+ */
48448
+ static customElementToSvg(obj, rasterSafeForCanvas = false) {
48449
+ const transform = this.buildTransform(obj);
48450
+ const opacity = obj.opacity !== 1 ? ` opacity="${obj.opacity}"` : '';
48451
+ const scale = this.getObjectScale(obj);
48452
+ const width = obj.totalWidth / scale;
48453
+ const height = obj.totalHeight / scale;
48454
+ if (rasterSafeForCanvas) {
48455
+ // PNG export: render placeholder rect (foreignObject doesn't rasterize to PNG well)
48456
+ console.warn('[KritzelCustomElement SVG Export] Custom elements do not serialize well to PNG. Use SVG export or screenshot instead.');
48457
+ return `<g transform="${transform}"${opacity}>
48458
+ <rect x="0" y="0" width="${width}" height="${height}" fill="#f0f0f0" stroke="#999" stroke-width="1" />
48459
+ <text x="${width / 2}" y="${height / 2}" text-anchor="middle" dominant-baseline="middle" font-size="12" fill="#999">[Custom Element]</text>
48460
+ </g>`;
48545
48461
  }
48546
- const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
48547
- if (maxScrollTop === 0) {
48548
- return false;
48462
+ // SVG export: use foreignObject to embed HTML
48463
+ const html = obj.element?.outerHTML || '<div style="border:1px dashed #ccc">Empty</div>';
48464
+ return `<g transform="${transform}"${opacity}>
48465
+ <foreignObject x="0" y="0" width="${width}" height="${height}">
48466
+ <div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;overflow:hidden;">
48467
+ ${html}
48468
+ </div>
48469
+ </foreignObject>
48470
+ </g>`;
48471
+ }
48472
+ /**
48473
+ * Converts a KritzelGroup to SVG by recursively converting children.
48474
+ * @param group - The group object
48475
+ * @param theme - Theme for color resolution
48476
+ * @param imageDataUrls - Optional map of image id -> inlined data URL
48477
+ * @returns SVG element string
48478
+ */
48479
+ static groupToSvg(group, theme, imageDataUrls, rasterSafeForCanvas = false) {
48480
+ const children = group.children
48481
+ .sort((a, b) => a.zIndex - b.zIndex)
48482
+ .map(child => this.objectToSvgElement(child, theme, imageDataUrls, rasterSafeForCanvas))
48483
+ .filter(Boolean)
48484
+ .join('\n ');
48485
+ if (!children) {
48486
+ return '';
48549
48487
  }
48550
- const epsilon = 1;
48551
- const scrollingDown = deltaY > 0;
48552
- const scrollingUp = deltaY < 0;
48553
- if (scrollingDown) {
48554
- return element.scrollTop < maxScrollTop - epsilon;
48488
+ const opacity = group.opacity !== 1 ? ` opacity="${group.opacity}"` : '';
48489
+ return `<g${opacity}>
48490
+ ${children}
48491
+ </g>`;
48492
+ }
48493
+ /**
48494
+ * Builds a transform attribute string for an object.
48495
+ * Combines translation and rotation.
48496
+ * @param object - The object to build transform for
48497
+ * @returns Transform attribute value string
48498
+ */
48499
+ static buildTransform(object) {
48500
+ const transforms = [];
48501
+ const scale = this.getObjectScale(object);
48502
+ // Translation
48503
+ transforms.push(`translate(${object.translateX}, ${object.translateY})`);
48504
+ // Rotation around center
48505
+ if (object.rotation !== 0) {
48506
+ const centerX = object.totalWidth / 2 / scale;
48507
+ const centerY = object.totalHeight / 2 / scale;
48508
+ const degrees = object.rotation * (180 / Math.PI);
48509
+ transforms.push(`rotate(${degrees}, ${centerX}, ${centerY})`);
48555
48510
  }
48556
- if (scrollingUp) {
48557
- return element.scrollTop > epsilon;
48511
+ return transforms.join(' ');
48512
+ }
48513
+ /**
48514
+ * Returns a safe non-zero scale for export geometry calculations.
48515
+ */
48516
+ static getObjectScale(object) {
48517
+ const scale = object.scale ?? 1;
48518
+ return scale === 0 ? 1 : scale;
48519
+ }
48520
+ /**
48521
+ * Converts ProseMirror JSON content to HTML string.
48522
+ * @param content - ProseMirror document JSON
48523
+ * @param context - The text or shape object for styling context
48524
+ * @param theme - Theme for color resolution
48525
+ * @returns HTML string
48526
+ */
48527
+ static prosemirrorToHtml(content, _context, theme) {
48528
+ if (!content || !content.content) {
48529
+ return '';
48558
48530
  }
48559
- return false;
48531
+ return content.content
48532
+ .map((node) => this.nodeToHtml(node, theme))
48533
+ .join('');
48560
48534
  }
48561
- static canElementConsumeHorizontalWheel(element, deltaX) {
48562
- if (deltaX === 0) {
48563
- return false;
48535
+ /**
48536
+ * Converts a ProseMirror node to HTML.
48537
+ * @param node - ProseMirror node JSON
48538
+ * @param theme - Theme for color resolution
48539
+ * @returns HTML string
48540
+ */
48541
+ static nodeToHtml(node, theme) {
48542
+ if (!node)
48543
+ return '';
48544
+ switch (node.type) {
48545
+ case 'paragraph':
48546
+ const pContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
48547
+ return `<p style="margin: 0;">${pContent || '<br/>'}</p>`;
48548
+ case 'text':
48549
+ let text = this.escapeHtml(node.text || '');
48550
+ if (node.marks) {
48551
+ for (const mark of node.marks) {
48552
+ text = this.applyMark(text, mark, theme);
48553
+ }
48554
+ }
48555
+ return text;
48556
+ case 'hard_break':
48557
+ return '<br/>';
48558
+ case 'bullet_list':
48559
+ const blContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
48560
+ return `<ul style="margin: 0; padding-left: 1.5em;">${blContent}</ul>`;
48561
+ case 'ordered_list':
48562
+ const olContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
48563
+ return `<ol style="margin: 0; padding-left: 1.5em;">${olContent}</ol>`;
48564
+ case 'list_item':
48565
+ const liContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
48566
+ return `<li>${liContent}</li>`;
48567
+ default:
48568
+ // For unknown node types, try to render content
48569
+ if (node.content) {
48570
+ return node.content.map((n) => this.nodeToHtml(n, theme)).join('');
48571
+ }
48572
+ return '';
48564
48573
  }
48565
- const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth);
48566
- if (maxScrollLeft === 0) {
48567
- return false;
48574
+ }
48575
+ /**
48576
+ * Converts ProseMirror JSON content to plain-text lines.
48577
+ * Used by raster-safe SVG export, which cannot depend on HTML/foreignObject.
48578
+ */
48579
+ static prosemirrorToPlainTextLines(content) {
48580
+ if (!content || !Array.isArray(content.content)) {
48581
+ return [];
48568
48582
  }
48569
- const epsilon = 1;
48570
- const scrollingRight = deltaX > 0;
48571
- const scrollingLeft = deltaX < 0;
48572
- if (scrollingRight) {
48573
- return element.scrollLeft < maxScrollLeft - epsilon;
48583
+ const plainText = content.content
48584
+ .map((node) => this.nodeToPlainText(node))
48585
+ .join('')
48586
+ .replace(/\r\n/g, '\n')
48587
+ .replace(/\n+$/, '');
48588
+ if (!plainText) {
48589
+ return [];
48574
48590
  }
48575
- if (scrollingLeft) {
48576
- return element.scrollLeft > epsilon;
48591
+ return plainText.split('\n');
48592
+ }
48593
+ /**
48594
+ * Converts a ProseMirror node tree into plain text.
48595
+ */
48596
+ static nodeToPlainText(node) {
48597
+ if (!node) {
48598
+ return '';
48577
48599
  }
48578
- return false;
48600
+ switch (node.type) {
48601
+ case 'text':
48602
+ return node.text || '';
48603
+ case 'hard_break':
48604
+ return '\n';
48605
+ case 'paragraph': {
48606
+ const paragraph = Array.isArray(node.content)
48607
+ ? node.content.map((child) => this.nodeToPlainText(child)).join('')
48608
+ : '';
48609
+ return `${paragraph}\n`;
48610
+ }
48611
+ case 'list_item': {
48612
+ const item = Array.isArray(node.content)
48613
+ ? node.content.map((child) => this.nodeToPlainText(child)).join('')
48614
+ : '';
48615
+ return `- ${item.trim()}\n`;
48616
+ }
48617
+ default:
48618
+ return Array.isArray(node.content)
48619
+ ? node.content.map((child) => this.nodeToPlainText(child)).join('')
48620
+ : '';
48621
+ }
48622
+ }
48623
+ /**
48624
+ * Applies a ProseMirror mark to text.
48625
+ * @param text - The text to wrap
48626
+ * @param mark - The mark to apply
48627
+ * @param theme - Theme for color resolution
48628
+ * @returns Wrapped HTML string
48629
+ */
48630
+ static applyMark(text, mark, _theme) {
48631
+ switch (mark.type) {
48632
+ case 'bold':
48633
+ case 'strong':
48634
+ return `<strong>${text}</strong>`;
48635
+ case 'italic':
48636
+ case 'em':
48637
+ return `<em>${text}</em>`;
48638
+ case 'underline':
48639
+ return `<u>${text}</u>`;
48640
+ case 'strike':
48641
+ return `<s>${text}</s>`;
48642
+ case 'code':
48643
+ return `<code>${text}</code>`;
48644
+ case 'link':
48645
+ const href = mark.attrs?.href || '#';
48646
+ return `<a href="${this.escapeHtml(href)}">${text}</a>`;
48647
+ default:
48648
+ return text;
48649
+ }
48650
+ }
48651
+ /**
48652
+ * Escapes HTML special characters.
48653
+ * @param str - String to escape
48654
+ * @returns Escaped string
48655
+ */
48656
+ static escapeHtml(str) {
48657
+ return str
48658
+ .replace(/&/g, '&amp;')
48659
+ .replace(/</g, '&lt;')
48660
+ .replace(/>/g, '&gt;')
48661
+ .replace(/"/g, '&quot;')
48662
+ .replace(/'/g, '&#039;');
48579
48663
  }
48580
48664
  }
48581
48665
 
@@ -49772,10 +49856,7 @@ const KritzelEngine = /*@__PURE__*/ proxyCustomElement(class KritzelEngine exten
49772
49856
  console.error('Failed to export viewport as PNG: screenshot could not be generated');
49773
49857
  return;
49774
49858
  }
49775
- const link = document.createElement('a');
49776
- link.download = filename;
49777
- link.href = dataUrl;
49778
- link.click();
49859
+ this.triggerDataUrlDownload(dataUrl, filename);
49779
49860
  }
49780
49861
  catch (error) {
49781
49862
  console.error('Failed to export viewport as PNG:', error);
@@ -49797,129 +49878,57 @@ const KritzelEngine = /*@__PURE__*/ proxyCustomElement(class KritzelEngine exten
49797
49878
  console.error('Failed to export viewport as SVG: screenshot could not be generated');
49798
49879
  return;
49799
49880
  }
49800
- const link = document.createElement('a');
49801
- link.download = filename;
49802
- link.href = dataUrl;
49803
- link.click();
49881
+ this.triggerDataUrlDownload(dataUrl, filename);
49804
49882
  }
49805
49883
  catch (error) {
49806
49884
  console.error('Failed to export viewport as SVG:', error);
49807
49885
  }
49808
49886
  }
49809
49887
  /**
49810
- * Generates an SVG string from the currently selected objects.
49811
- * Creates clean, standards-compliant SVG markup by directly serializing object properties.
49812
- * @param options - Optional export settings (theme, padding, raster-safe mode for canvas export).
49813
- * @returns SVG markup string, or null if no objects are selected.
49888
+ * Returns export formats supported by the currently selected object.
49889
+ * Only returns formats when exactly one object is selected.
49814
49890
  */
49815
- async getSelectedObjectsAsSvgString(options) {
49816
- const selectedObjects = await this.getSelectedObjects();
49817
- if (!selectedObjects || selectedObjects.length === 0) {
49818
- return null;
49891
+ async getSelectedObjectSupportedExportFormats() {
49892
+ const selected = this.getSingleSelectedExportableObject();
49893
+ if (!selected) {
49894
+ return [];
49819
49895
  }
49820
- const theme = options?.theme ?? this.core.themeManager.getStoredTheme();
49821
- // Inline image bytes as data URLs so they survive both standalone SVG
49822
- // files and rasterization to PNG (a `blob:` href is not loaded when an
49823
- // SVG is drawn into a canvas, which otherwise yields a broken image).
49824
- const imageDataUrls = await KritzelSvgExportHelper.resolveImageDataUrls(selectedObjects, this.core.assetResolver);
49825
- return KritzelSvgExportHelper.generateSvg(selectedObjects, {
49826
- theme,
49827
- padding: options?.padding ?? 0,
49828
- includeXmlDeclaration: true,
49829
- rasterSafeForCanvas: options?.rasterSafeForCanvas ?? false,
49830
- imageDataUrls,
49831
- });
49896
+ return selected.getSupportedExportFormats();
49832
49897
  }
49833
49898
  /**
49834
- * Exports the currently selected objects as an SVG file and triggers a browser download.
49835
- * Creates clean, standards-compliant SVG markup suitable for import into vector editors.
49836
- * @param options - Optional export settings (theme, padding).
49899
+ * Checks whether the currently selected object supports a specific export format.
49900
+ * Returns false when none or multiple objects are selected.
49837
49901
  */
49838
- async exportSelectedObjectsAsSvg(options) {
49839
- try {
49840
- const svgString = await this.getSelectedObjectsAsSvgString(options);
49841
- if (!svgString) {
49842
- console.warn('No objects selected for SVG export');
49843
- return;
49844
- }
49845
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
49846
- const filename = `selection-${timestamp}.svg`;
49847
- const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
49848
- const url = URL.createObjectURL(blob);
49849
- const link = document.createElement('a');
49850
- link.download = filename;
49851
- link.href = url;
49852
- link.click();
49853
- URL.revokeObjectURL(url);
49854
- }
49855
- catch (error) {
49856
- console.error('Failed to export selected objects as SVG:', error);
49902
+ async canExportSelectedObjectAs(format) {
49903
+ const selected = this.getSingleSelectedExportableObject();
49904
+ if (!selected) {
49905
+ return false;
49857
49906
  }
49907
+ return selected.getSupportedExportFormats().includes(format);
49858
49908
  }
49859
49909
  /**
49860
- * Generates a PNG data URL from the currently selected objects.
49861
- * Converts the SVG representation to a rasterized PNG image.
49862
- * @param options - Optional export settings (theme, padding, scale for resolution).
49863
- * @returns PNG data URL string, or null if no objects are selected.
49910
+ * Exports the currently selected object in the requested format and triggers a file download.
49911
+ * Only supports single selection.
49864
49912
  */
49865
- async getSelectedObjectsAsPngDataUrl(options) {
49866
- const svgString = await this.getSelectedObjectsAsSvgString({
49867
- theme: options?.theme,
49868
- padding: options?.padding,
49869
- // Avoid foreignObject-based text serialization, which taints the
49870
- // canvas in SVG->PNG conversion paths.
49871
- rasterSafeForCanvas: true,
49872
- });
49873
- if (!svgString) {
49874
- return null;
49913
+ async exportSelectedObject(format) {
49914
+ const selected = this.getSingleSelectedExportableObject();
49915
+ if (!selected) {
49916
+ return;
49875
49917
  }
49876
- const scale = options?.scale ?? 2; // Default 2x for better quality
49877
- return new Promise((resolve, reject) => {
49878
- const img = new Image();
49879
- const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
49880
- const url = URL.createObjectURL(svgBlob);
49881
- img.onload = () => {
49882
- const canvas = document.createElement('canvas');
49883
- canvas.width = img.width * scale;
49884
- canvas.height = img.height * scale;
49885
- const ctx = canvas.getContext('2d');
49886
- if (!ctx) {
49887
- URL.revokeObjectURL(url);
49888
- reject(new Error('Failed to get canvas context'));
49889
- return;
49890
- }
49891
- ctx.scale(scale, scale);
49892
- ctx.drawImage(img, 0, 0);
49893
- URL.revokeObjectURL(url);
49894
- resolve(canvas.toDataURL('image/png'));
49895
- };
49896
- img.onerror = () => {
49897
- URL.revokeObjectURL(url);
49898
- reject(new Error('Failed to load SVG for PNG conversion'));
49899
- };
49900
- img.src = url;
49901
- });
49902
- }
49903
- /**
49904
- * Exports the currently selected objects as a PNG file and triggers a browser download.
49905
- * @param options - Optional export settings (theme, padding, scale for resolution).
49906
- */
49907
- async exportSelectedObjectsAsPng(options) {
49908
49918
  try {
49909
- const dataUrl = await this.getSelectedObjectsAsPngDataUrl(options);
49919
+ const dataUrl = await selected.exportAs(format, this.getObjectExportContext());
49910
49920
  if (!dataUrl) {
49911
- console.warn('No objects selected for PNG export');
49921
+ console.error(`Failed to export selected object as ${format}: export returned no data URL`);
49912
49922
  return;
49913
49923
  }
49924
+ const activeWorkspaceName = this.core.store.state?.activeWorkspace?.name || 'workspace';
49925
+ const objectName = selected.__class__.replace(/^Kritzel/, '').toLowerCase() || 'object';
49914
49926
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
49915
- const filename = `selection-${timestamp}.png`;
49916
- const link = document.createElement('a');
49917
- link.download = filename;
49918
- link.href = dataUrl;
49919
- link.click();
49927
+ const filename = `${activeWorkspaceName}-${objectName}-${timestamp}.${format}`;
49928
+ this.triggerDataUrlDownload(dataUrl, filename);
49920
49929
  }
49921
49930
  catch (error) {
49922
- console.error('Failed to export selected objects as PNG:', error);
49931
+ console.error(`Failed to export selected object as ${format}:`, error);
49923
49932
  }
49924
49933
  }
49925
49934
  /**
@@ -50114,6 +50123,73 @@ const KritzelEngine = /*@__PURE__*/ proxyCustomElement(class KritzelEngine exten
50114
50123
  const response = await fetch(dataUrl);
50115
50124
  return response.blob();
50116
50125
  }
50126
+ triggerDataUrlDownload(dataUrl, filename) {
50127
+ const link = document.createElement('a');
50128
+ link.download = filename;
50129
+ link.href = dataUrl;
50130
+ link.click();
50131
+ }
50132
+ getSingleSelectedExportableObject() {
50133
+ const selectedObjects = this.core.store.selectionGroup?.objects ?? [];
50134
+ if (selectedObjects.length !== 1) {
50135
+ return null;
50136
+ }
50137
+ const [selected] = selectedObjects;
50138
+ if (!ObjectHelper.isKritzelExportable(selected)) {
50139
+ return null;
50140
+ }
50141
+ return selected;
50142
+ }
50143
+ getObjectExportContext() {
50144
+ return {
50145
+ getObjectAsSvgDataUrl: async (object, options) => {
50146
+ const svg = await this.getObjectAsSvgMarkup(object, options?.rasterSafeForCanvas ?? false);
50147
+ return svg ? this.svgMarkupToDataUrl(svg) : null;
50148
+ },
50149
+ getObjectAsPngDataUrl: async (object) => {
50150
+ const svg = await this.getObjectAsSvgMarkup(object, true);
50151
+ return svg ? this.svgMarkupToPngDataUrl(svg) : null;
50152
+ },
50153
+ };
50154
+ }
50155
+ async getObjectAsSvgMarkup(object, rasterSafeForCanvas) {
50156
+ const imageDataUrls = await KritzelSvgExportHelper.resolveImageDataUrls([object], this.core.assetResolver);
50157
+ const svg = KritzelSvgExportHelper.generateSvg([object], {
50158
+ theme: this.theme,
50159
+ rasterSafeForCanvas,
50160
+ imageDataUrls,
50161
+ });
50162
+ return svg || null;
50163
+ }
50164
+ svgMarkupToDataUrl(svgMarkup) {
50165
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgMarkup)}`;
50166
+ }
50167
+ async svgMarkupToPngDataUrl(svgMarkup) {
50168
+ const svgBlob = new Blob([svgMarkup], { type: 'image/svg+xml;charset=utf-8' });
50169
+ const svgBlobUrl = URL.createObjectURL(svgBlob);
50170
+ try {
50171
+ const img = await new Promise((resolve, reject) => {
50172
+ const element = new Image();
50173
+ element.onload = () => resolve(element);
50174
+ element.onerror = err => reject(err);
50175
+ element.src = svgBlobUrl;
50176
+ });
50177
+ const width = Math.max(1, Math.ceil(img.naturalWidth || img.width));
50178
+ const height = Math.max(1, Math.ceil(img.naturalHeight || img.height));
50179
+ const canvas = document.createElement('canvas');
50180
+ canvas.width = width;
50181
+ canvas.height = height;
50182
+ const context = canvas.getContext('2d');
50183
+ if (!context) {
50184
+ throw new Error('Could not create 2D canvas context for PNG export');
50185
+ }
50186
+ context.drawImage(img, 0, 0, width, height);
50187
+ return canvas.toDataURL('image/png');
50188
+ }
50189
+ finally {
50190
+ URL.revokeObjectURL(svgBlobUrl);
50191
+ }
50192
+ }
50117
50193
  getImportedWorkspaceName(rawName) {
50118
50194
  if (typeof rawName === 'string' && rawName.trim().length > 0) {
50119
50195
  return rawName;
@@ -50994,7 +51070,9 @@ const KritzelEngine = /*@__PURE__*/ proxyCustomElement(class KritzelEngine exten
50994
51070
  this.emitObjectsInViewportChange();
50995
51071
  }
50996
51072
  }
50997
- 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" }, 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: {
51073
+ 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: {
51074
+ color: 'var(--kritzel-global-text-primary)',
51075
+ } }, 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: {
50998
51076
  transform: `matrix(${this.core.store.state?.scale}, 0, 0, ${this.core.store.state?.scale}, ${this.core.store.state?.translateX}, ${this.core.store.state?.translateY})`,
50999
51077
  opacity: this._sceneBootstrapOpacity.toString(),
51000
51078
  transition: this._sceneBootstrapTransitionEnabled ? 'opacity 220ms ease-out' : 'none',
@@ -51177,6 +51255,7 @@ const KritzelEngine = /*@__PURE__*/ proxyCustomElement(class KritzelEngine exten
51177
51255
  left: `${object.totalWidth}px`,
51178
51256
  top: '0',
51179
51257
  zIndex: (object.zIndex + 2).toString(),
51258
+ color: 'var(--kritzel-global-text-primary)',
51180
51259
  } }, h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "Id: ", object.id), h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "userId: ", object.userId), h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "width: ", object.width), h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "height: ", object.height), h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "translateX: ", object.translateX), h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "translateY: ", object.translateY), h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "scale: ", object.scale), h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "rotationDegrees: ", object.rotationDegrees), h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "zIndex: ", object.zIndex), h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "isSelected: ", object.isSelected ? 'true' : 'false'), KritzelClassHelper.isInstanceOf(object, 'KritzelCustomElement') && (h("div", null, h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "isInteractive: ", object.isInteractive ? 'true' : 'false'))), KritzelClassHelper.isInstanceOf(object, 'KritzelImage') && (h("div", null, h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "assetId: ", object.assetId), h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "mimeType: ", object.mimeType))))), (this.core.displaySelectionGroupUI(object) || this.core.displaySelectionLineUI(object)) &&
51181
51260
  (() => {
51182
51261
  const isSelectionGroup = KritzelClassHelper.isInstanceOf(object, 'KritzelSelectionGroup');
@@ -51377,8 +51456,12 @@ const KritzelEngine = /*@__PURE__*/ proxyCustomElement(class KritzelEngine exten
51377
51456
  strokeDasharray: data.dashArray,
51378
51457
  strokeLinecap: 'round',
51379
51458
  } }))), h("circle", { cx: data.centerX, cy: data.centerY, r: data.indicatorRadius, style: {
51380
- fill: isActiveSnap ? 'var(--kritzel-snap-indicator-fill, rgba(59, 130, 246, 0.3))' : 'rgba(59, 130, 246, 0.18)',
51381
- stroke: isActiveSnap ? 'var(--kritzel-snap-indicator-stroke, #007bff)' : 'rgba(59, 130, 246, 0.5)',
51459
+ fill: isActiveSnap
51460
+ ? 'var(--kritzel-snap-indicator-fill, rgba(59, 130, 246, 0.3))'
51461
+ : 'var(--kritzel-snap-indicator-fill-inactive, var(--kritzel-snap-indicator-fill, rgba(59, 130, 246, 0.18)))',
51462
+ stroke: isActiveSnap
51463
+ ? 'var(--kritzel-snap-indicator-stroke, #007bff)'
51464
+ : 'var(--kritzel-snap-indicator-stroke-inactive, var(--kritzel-snap-indicator-stroke, rgba(59, 130, 246, 0.5)))',
51382
51465
  strokeWidth: data.indicatorStrokeWidth,
51383
51466
  } }))));
51384
51467
  })(), (() => {
@@ -51586,10 +51669,9 @@ const KritzelEngine = /*@__PURE__*/ proxyCustomElement(class KritzelEngine exten
51586
51669
  "getScreenshot": [64],
51587
51670
  "exportViewportAsPng": [64],
51588
51671
  "exportViewportAsSvg": [64],
51589
- "getSelectedObjectsAsSvgString": [64],
51590
- "exportSelectedObjectsAsSvg": [64],
51591
- "getSelectedObjectsAsPngDataUrl": [64],
51592
- "exportSelectedObjectsAsPng": [64],
51672
+ "getSelectedObjectSupportedExportFormats": [64],
51673
+ "canExportSelectedObjectAs": [64],
51674
+ "exportSelectedObject": [64],
51593
51675
  "exportAsJson": [64],
51594
51676
  "importFromJson": [64],
51595
51677
  "loadObjectsFromJson": [64],