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,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  var index = require('./index-Xav9JFHg.js');
4
- var schema_constants = require('./schema.constants-C8hqt--r.js');
4
+ var schema_constants = require('./schema.constants-ByNZ_qAj.js');
5
5
  var Y = require('yjs');
6
6
  require('y-indexeddb');
7
7
  require('y-websocket');
@@ -2265,6 +2265,19 @@ const KritzelEditor = class {
2265
2265
  group: 'clipboard',
2266
2266
  action: (menu, _) => this.engineRef.paste(menu.x, menu.y),
2267
2267
  },
2268
+ {
2269
+ label: 'menu.export',
2270
+ icon: 'upload',
2271
+ group: 'clipboard',
2272
+ visible: (_, objects) => this.canExportObjects(objects),
2273
+ action: (_, objects) => {
2274
+ const format = this.getPreferredExportFormat(objects);
2275
+ if (!format) {
2276
+ return;
2277
+ }
2278
+ void this.engineRef.exportSelectedObject(format);
2279
+ },
2280
+ },
2268
2281
  {
2269
2282
  label: 'menu.order',
2270
2283
  icon: 'ordering',
@@ -2306,16 +2319,43 @@ const KritzelEditor = class {
2306
2319
  },
2307
2320
  ],
2308
2321
  },
2322
+ { label: 'menu.delete', icon: 'delete', group: 'edit', action: () => this.engineRef.delete() },
2323
+ ];
2324
+ moreMenuItems = [
2325
+ {
2326
+ id: 'share',
2327
+ label: 'menu.share',
2328
+ icon: 'share',
2329
+ isVisible: async () => schema_constants.hasRemoteSyncProvider(this.syncConfig),
2330
+ action: () => {
2331
+ if (!this.isLoggedIn && this.loginConfig) {
2332
+ this.openLoginDialog();
2333
+ return;
2334
+ }
2335
+ this.shareDialogRef.open();
2336
+ },
2337
+ },
2309
2338
  {
2339
+ id: 'export',
2310
2340
  label: 'menu.export',
2341
+ icon: 'upload',
2342
+ action: async () => {
2343
+ const preview = await this.engineRef.getScreenshot('png');
2344
+ this.exportRef.open(preview ?? undefined);
2345
+ },
2346
+ },
2347
+ {
2348
+ id: 'import',
2349
+ label: 'menu.import',
2311
2350
  icon: 'download',
2312
- group: 'export',
2313
- children: [
2314
- { label: 'menu.exportAsSvg', icon: 'download', action: () => this.engineRef.exportSelectedObjectsAsSvg() },
2315
- { label: 'menu.exportAsPng', icon: 'download', action: () => this.engineRef.exportSelectedObjectsAsPng() },
2316
- ],
2351
+ action: () => this.engineRef.importFromFile(),
2352
+ },
2353
+ {
2354
+ id: 'settings',
2355
+ label: 'menu.settings',
2356
+ icon: 'settings',
2357
+ action: () => this.settingsRef.open(),
2317
2358
  },
2318
- { label: 'menu.delete', icon: 'delete', group: 'edit', action: () => this.engineRef.delete() },
2319
2359
  ];
2320
2360
  themes;
2321
2361
  theme = 'light';
@@ -2387,6 +2427,7 @@ const KritzelEditor = class {
2387
2427
  isNotificationDismissing = false;
2388
2428
  contextMenuState;
2389
2429
  normalizedControls = [];
2430
+ resolvedMoreMenuItems = [];
2390
2431
  notificationDismissTimeout;
2391
2432
  notificationDismissAnimationTimeout;
2392
2433
  notificationDisplayDurationMs = 5000;
@@ -2429,6 +2470,18 @@ const KritzelEditor = class {
2429
2470
  onControlsChange(newValue) {
2430
2471
  this.normalizedControls = this.normalizeControls(newValue);
2431
2472
  }
2473
+ onMoreMenuItemsChange() {
2474
+ this.recomputeMoreMenuItems();
2475
+ }
2476
+ onSyncConfigChange() {
2477
+ this.recomputeMoreMenuItems();
2478
+ }
2479
+ onLoginConfigChange() {
2480
+ this.recomputeMoreMenuItems();
2481
+ }
2482
+ onUserChange() {
2483
+ this.recomputeMoreMenuItems();
2484
+ }
2432
2485
  onActiveWorkspaceIdChange(newId) {
2433
2486
  // If no ID is provided, do nothing (allows other logic to control activeWorkspace)
2434
2487
  if (!newId) {
@@ -2478,6 +2531,7 @@ const KritzelEditor = class {
2478
2531
  }
2479
2532
  this.resolvedTerms = await this.engineRef.getResolvedTerms();
2480
2533
  this.availableLocaleOptions = await this.engineRef.getAvailableLocaleOptions();
2534
+ this.recomputeMoreMenuItems();
2481
2535
  }
2482
2536
  onTouchStart(event) {
2483
2537
  if (event.cancelable) {
@@ -2638,11 +2692,14 @@ const KritzelEditor = class {
2638
2692
  async exportViewportAsSvg(options) {
2639
2693
  return this.engineRef.exportViewportAsSvg(options);
2640
2694
  }
2641
- async exportSelectedObjectsAsPng() {
2642
- return this.engineRef.exportSelectedObjectsAsPng();
2695
+ async getSelectedObjectSupportedExportFormats() {
2696
+ return this.engineRef.getSelectedObjectSupportedExportFormats();
2697
+ }
2698
+ async canExportSelectedObjectAs(format) {
2699
+ return this.engineRef.canExportSelectedObjectAs(format);
2643
2700
  }
2644
- async exportSelectedObjectsAsSvg() {
2645
- return this.engineRef.exportSelectedObjectsAsSvg();
2701
+ async exportSelectedObject(format) {
2702
+ return this.engineRef.exportSelectedObject(format);
2646
2703
  }
2647
2704
  async downloadAsJson(filename) {
2648
2705
  return this.engineRef.downloadAsJson(filename);
@@ -2709,6 +2766,7 @@ const KritzelEditor = class {
2709
2766
  currentUserDialogRef;
2710
2767
  componentWillLoad() {
2711
2768
  this.normalizedControls = this.normalizeControls(this.controls);
2769
+ this.recomputeMoreMenuItems();
2712
2770
  this.loadSettingsFromStorage();
2713
2771
  this.applyTheme();
2714
2772
  }
@@ -2729,13 +2787,18 @@ const KritzelEditor = class {
2729
2787
  };
2730
2788
  });
2731
2789
  }
2790
+ recomputeMoreMenuItems() {
2791
+ if (this.moreMenuItems) {
2792
+ this.resolvedMoreMenuItems = this.moreMenuItems;
2793
+ return;
2794
+ }
2795
+ }
2732
2796
  applyTheme() {
2733
2797
  const themeObj = this.resolveThemeObject();
2734
2798
  schema_constants.ThemeHelper.applyThemeToElement(this.host, themeObj);
2735
2799
  }
2736
2800
  resolveThemeObject() {
2737
- return this.themes?.find(t => t.name === this.theme)
2738
- ?? (this.theme === 'dark' ? schema_constants.darkTheme : schema_constants.lightTheme);
2801
+ return this.themes?.find(t => t.name === this.theme) ?? (this.theme === 'dark' ? schema_constants.darkTheme : schema_constants.lightTheme);
2739
2802
  }
2740
2803
  componentDidLoad() {
2741
2804
  this.registerCustomSvgIcons();
@@ -2770,7 +2833,7 @@ const KritzelEditor = class {
2770
2833
  syncConfig: this.syncConfig,
2771
2834
  assetStorageConfig: this.assetStorageConfig,
2772
2835
  loginConfig: this.loginConfig,
2773
- theme: this.theme
2836
+ theme: this.theme,
2774
2837
  });
2775
2838
  }
2776
2839
  async onEngineReady(event) {
@@ -2933,43 +2996,6 @@ const KritzelEditor = class {
2933
2996
  }
2934
2997
  this.themeChange.emit(event.detail.theme);
2935
2998
  }
2936
- get moreMenuItems() {
2937
- return [
2938
- {
2939
- id: 'share',
2940
- label: this.resolvedTerms['menu.share'] ?? 'Share',
2941
- icon: 'share',
2942
- action: () => {
2943
- if (!this.isLoggedIn && this.loginConfig) {
2944
- this.openLoginDialog();
2945
- return;
2946
- }
2947
- this.shareDialogRef.open();
2948
- },
2949
- },
2950
- {
2951
- id: 'export',
2952
- label: this.resolvedTerms['menu.export'] ?? 'Export',
2953
- icon: 'upload',
2954
- action: async () => {
2955
- const preview = await this.engineRef.getScreenshot('png');
2956
- this.exportRef.open(preview ?? undefined);
2957
- },
2958
- },
2959
- {
2960
- id: 'import',
2961
- label: this.resolvedTerms['menu.import'] ?? 'Import',
2962
- icon: 'download',
2963
- action: () => this.engineRef.importFromFile(),
2964
- },
2965
- {
2966
- id: 'settings',
2967
- label: this.resolvedTerms['menu.settings'] ?? 'Settings',
2968
- icon: 'settings',
2969
- action: () => this.settingsRef.open(),
2970
- }
2971
- ];
2972
- }
2973
2999
  handleToggleIsPublic = async (event) => {
2974
3000
  const isPublic = event.detail;
2975
3001
  const activeWorkspace = await this.engineRef.getActiveWorkspace();
@@ -2999,6 +3025,28 @@ const KritzelEditor = class {
2999
3025
  get isLoggedIn() {
3000
3026
  return !!this.user && !this.user.isGuest;
3001
3027
  }
3028
+ canExportObjectsAs(objects, format) {
3029
+ if (!objects || objects.length !== 1) {
3030
+ return false;
3031
+ }
3032
+ const [selected] = objects;
3033
+ if (!schema_constants.ObjectHelper.isKritzelExportable(selected)) {
3034
+ return false;
3035
+ }
3036
+ return selected.getSupportedExportFormats().includes(format);
3037
+ }
3038
+ canExportObjects(objects) {
3039
+ return this.canExportObjectsAs(objects, 'png') || this.canExportObjectsAs(objects, 'svg');
3040
+ }
3041
+ getPreferredExportFormat(objects) {
3042
+ if (this.canExportObjectsAs(objects, 'png')) {
3043
+ return 'png';
3044
+ }
3045
+ if (this.canExportObjectsAs(objects, 'svg')) {
3046
+ return 'svg';
3047
+ }
3048
+ return null;
3049
+ }
3002
3050
  async openLoginDialog() {
3003
3051
  this.loginDialogRef?.open();
3004
3052
  }
@@ -3175,41 +3223,41 @@ const KritzelEditor = class {
3175
3223
  const isLoggedIn = this.isLoggedIn;
3176
3224
  const shouldShowCurrentUser = isLoggedIn;
3177
3225
  const shouldShowLoginButton = this.isReady && !!this.loginConfig && !isLoggedIn;
3178
- return (index.h(index.Host, { key: 'ab06876e3a07e97c3788113ffd0483bbb2819d5b' }, index.h("div", { key: 'e393eacfad2b224a720e5a97b047af8dd3ce97a5', class: "editor-content", style: {
3226
+ return (index.h(index.Host, { key: '3119d6909544f0d15bd805f65ccf3f18469dc11d' }, index.h("div", { key: '7528eec8df968cd7814a2343fa3aa792802b9856', class: "editor-content", style: {
3179
3227
  opacity: this.isEditorVisible ? '1' : '0',
3180
3228
  visibility: this.isEditorVisible ? 'visible' : 'hidden',
3181
3229
  transition: 'opacity 0.2s ease-in-out, visibility 0.2s ease-in-out',
3182
- } }, index.h("div", { key: 'd9dcbd6859bea28d75c7f8b0cc26ea3ba58b2433', class: "top-left-buttons" }, index.h("kritzel-workspace-manager", { key: '945953a6c95520316c4e0f35503a824414bd3c7a', visible: this.isWorkspaceManagerVisible, workspaces: this.workspaces, activeWorkspace: this.activeWorkspace, terms: this.resolvedTerms, onWorkspaceChange: event => (this.activeWorkspace = event.detail), onIsWorkspaceManagerReady: () => (this.isWorkspaceManagerReady = true) }), index.h("kritzel-back-to-content", { key: '8b345bf6ce0ec33ca35f1f630af2050b95bc6b74', visible: this.isBackToContentButtonVisible, text: this.resolvedTerms['backToContent.label'] ?? 'Back to content', onBackToContent: () => this.backToContent() })), this.activeNotification && (index.h("div", { key: '1b965de38a7f1f40d5d91431801e873289416924', class: "top-center-notification-layer", role: "presentation" }, index.h("div", { key: 'f165dc7acc5e1bc7923fec217ebc16fe4a3f2ece', class: { 'top-center-notification-container': true, 'is-dismissing': this.isNotificationDismissing }, role: "presentation" }, index.h("kritzel-notification-card", { key: 'c5cf7edc1f921e35310c89449f5a043dda171a04', notification: this.activeNotification, locale: this.locale, onDismiss: this.dismissNotification, onHoverChange: event => this.handleNotificationHoverChange(event) })))), this.contextMenuState && this.contextMenuState.items.length > 0 && (index.h("kritzel-context-menu", { key: 'fc3417276f0034e7fc7e8360d3f3ce57106146b2', class: "context-menu", items: this.contextMenuState.items, objects: this.contextMenuState.objects, style: {
3230
+ } }, index.h("div", { key: '71f0dba52cddc6e28ea7f7b3b6ff78c32aa19acf', class: "top-left-buttons" }, index.h("kritzel-workspace-manager", { key: '579826701b13fb4b12dbdbf0b31b05031f01c3d1', visible: this.isWorkspaceManagerVisible, workspaces: this.workspaces, activeWorkspace: this.activeWorkspace, terms: this.resolvedTerms, onWorkspaceChange: event => (this.activeWorkspace = event.detail), onIsWorkspaceManagerReady: () => (this.isWorkspaceManagerReady = true) }), index.h("kritzel-back-to-content", { key: '84fb52fba5f898cf1034780f87f7ce49d370cdfe', visible: this.isBackToContentButtonVisible, text: this.resolvedTerms['backToContent.label'] ?? 'Back to content', onBackToContent: () => this.backToContent() })), this.activeNotification && (index.h("div", { key: 'ecca20769db06f56bd52199b04f9e44c74bf525d', class: "top-center-notification-layer", role: "presentation" }, index.h("div", { key: 'aa2232646d7d77d70af04c8b413e2eb8033a00eb', class: { 'top-center-notification-container': true, 'is-dismissing': this.isNotificationDismissing }, role: "presentation" }, index.h("kritzel-notification-card", { key: 'fa111382e6fdebf8e5944c6148cad9583bc8b68d', notification: this.activeNotification, locale: this.locale, onDismiss: this.dismissNotification, onHoverChange: event => this.handleNotificationHoverChange(event) })))), this.contextMenuState && this.contextMenuState.items.length > 0 && (index.h("kritzel-context-menu", { key: '420caf2b6bfb79f3ebda3537cc379f8735eb8c09', class: "context-menu", items: this.contextMenuState.items, objects: this.contextMenuState.objects, style: {
3183
3231
  position: 'absolute',
3184
3232
  left: `${this.contextMenuState.position.x}px`,
3185
3233
  top: `${this.contextMenuState.position.y}px`,
3186
3234
  zIndex: '10002',
3187
- }, onActionSelected: event => this.handleContextMenuActionSelected(event), onClose: () => this.hideContextMenu() })), index.h("kritzel-engine", { key: '0c9df0386d38a87f50d324cfc51307a1cb947a58', ref: el => {
3235
+ }, onActionSelected: event => this.handleContextMenuActionSelected(event), onClose: () => this.hideContextMenu() })), index.h("kritzel-engine", { key: 'd10589d02c626712e10f55b07f716907d83b28c1', ref: el => {
3188
3236
  if (el) {
3189
3237
  this.engineRef = el;
3190
3238
  }
3191
- }, workspace: this.activeWorkspace, activeWorkspaceId: this.activeWorkspaceId, editorId: this.editorId, syncConfig: this.syncConfig, assetStorageConfig: this.assetStorageConfig, user: this.user, scaleMax: this.scaleMax, lockDrawingScale: this.lockDrawingScale, isObjectDistanceFadingActive: this.isObjectDistanceFadingActive, scaleMin: this.scaleMin, cursorTarget: this.cursorTarget, isLoading: this.isLoading, viewportBoundaryLeft: this.viewportBoundaryLeft, viewportBoundaryRight: this.viewportBoundaryRight, viewportBoundaryTop: this.viewportBoundaryTop, viewportBoundaryBottom: this.viewportBoundaryBottom, isPanningEnabled: this.isPanningEnabled, isZoomingEnabled: this.isZoomingEnabled, theme: this.theme, themes: this.themes, licenseKey: this.licenseKey, locale: this.locale, locales: this.locales, fallbackLocale: this.fallbackLocale, debugInfo: this.debugInfo, globalContextMenuItems: this.globalContextMenuItems, objectContextMenuItems: this.objectContextMenuItems, onIsEngineReady: event => this.onEngineReady(event), onWorkspacesChange: event => this.handleWorkspacesChange(event), onActiveWorkspaceChange: event => this.handleActiveWorkspaceChange(event), onObjectsChange: event => this.handleObjectsChange(event), onObjectsAdded: event => this.handleObjectsAdded(event), onObjectsRemoved: event => this.handleObjectsRemoved(event), onObjectsUpdated: event => this.handleObjectsUpdated(event), onUndoStateChange: event => this.handleUndoStateChange(event), onObjectsInViewportChange: event => this.handleObjectsInViewportChange(event), onViewportChange: event => this.handleViewportChange(event), onAwarenessChange: event => this.handleAwarenessChange(event), onNotificationsChange: event => this.handleNotificationsChange(event), onContextMenuStateChange: event => this.handleContextMenuStateChange(event) }), index.h("kritzel-controls", { key: '46d8e9c45c7bed24fc02d17c8fef836d596088d7', visible: this.isControlsVisible, class: { 'keyboard-open': this.isVirtualKeyboardOpen }, ref: el => {
3239
+ }, workspace: this.activeWorkspace, activeWorkspaceId: this.activeWorkspaceId, editorId: this.editorId, syncConfig: this.syncConfig, assetStorageConfig: this.assetStorageConfig, user: this.user, scaleMax: this.scaleMax, lockDrawingScale: this.lockDrawingScale, isObjectDistanceFadingActive: this.isObjectDistanceFadingActive, scaleMin: this.scaleMin, cursorTarget: this.cursorTarget, isLoading: this.isLoading, viewportBoundaryLeft: this.viewportBoundaryLeft, viewportBoundaryRight: this.viewportBoundaryRight, viewportBoundaryTop: this.viewportBoundaryTop, viewportBoundaryBottom: this.viewportBoundaryBottom, isPanningEnabled: this.isPanningEnabled, isZoomingEnabled: this.isZoomingEnabled, theme: this.theme, themes: this.themes, licenseKey: this.licenseKey, locale: this.locale, locales: this.locales, fallbackLocale: this.fallbackLocale, debugInfo: this.debugInfo, globalContextMenuItems: this.globalContextMenuItems, objectContextMenuItems: this.objectContextMenuItems, onIsEngineReady: event => this.onEngineReady(event), onWorkspacesChange: event => this.handleWorkspacesChange(event), onActiveWorkspaceChange: event => this.handleActiveWorkspaceChange(event), onObjectsChange: event => this.handleObjectsChange(event), onObjectsAdded: event => this.handleObjectsAdded(event), onObjectsRemoved: event => this.handleObjectsRemoved(event), onObjectsUpdated: event => this.handleObjectsUpdated(event), onUndoStateChange: event => this.handleUndoStateChange(event), onObjectsInViewportChange: event => this.handleObjectsInViewportChange(event), onViewportChange: event => this.handleViewportChange(event), onAwarenessChange: event => this.handleAwarenessChange(event), onNotificationsChange: event => this.handleNotificationsChange(event), onContextMenuStateChange: event => this.handleContextMenuStateChange(event) }), index.h("kritzel-controls", { key: '121480e948808fe6a44a94511becb834f9249897', visible: this.isControlsVisible, class: { 'keyboard-open': this.isVirtualKeyboardOpen }, ref: el => {
3192
3240
  if (el) {
3193
3241
  this.controlsRef = el;
3194
3242
  }
3195
- }, controls: this.normalizedControls, isUtilityPanelVisible: this.isUtilityPanelVisible, undoState: this.undoState ?? undefined, theme: this.theme, terms: this.resolvedTerms, onIsControlsReady: () => (this.isControlsReady = true) }), index.h("div", { key: 'e3ef94509a82fe435f827b66cf1813969b7dec66', class: "bottom-left-buttons" }, index.h("kritzel-zoom-panel", { key: '4e6cbdf85f550e6e3037dce3b910c0582ea0320b', visible: this.isZoomPanelVisible, disabled: !this.isZoomingEnabled, zoomPercent: this.currentZoomPercent, terms: this.resolvedTerms, onZoomIn: () => this.zoomIn(), onZoomOut: () => this.zoomOut() })), index.h("div", { key: 'a62df7f8a68cbed728b701894a5284c2bff39e1e', class: "top-right-buttons" }, index.h("kritzel-settings", { key: '0b6daf9ff43551df9f86431d398a32913a845925', ref: el => {
3243
+ }, controls: this.normalizedControls, isUtilityPanelVisible: this.isUtilityPanelVisible, undoState: this.undoState ?? undefined, theme: this.theme, terms: this.resolvedTerms, onIsControlsReady: () => (this.isControlsReady = true) }), index.h("div", { key: 'e84f31858a6a45fb9f7bcc3686122b349551618b', class: "bottom-left-buttons" }, index.h("kritzel-zoom-panel", { key: 'a25d93767a124e1a2fdcdd4b77706ec7d032234c', visible: this.isZoomPanelVisible, disabled: !this.isZoomingEnabled, zoomPercent: this.currentZoomPercent, terms: this.resolvedTerms, onZoomIn: () => this.zoomIn(), onZoomOut: () => this.zoomOut() })), index.h("div", { key: '49810743d464d4e019670859f6ed31d9c12cd560', class: "top-right-buttons" }, index.h("kritzel-settings", { key: '57daff63b4ec1de36f94be99948d0d0aa091c522', ref: el => {
3196
3244
  if (el) {
3197
3245
  this.settingsRef = el;
3198
3246
  }
3199
- }, shortcuts: this.shortcuts, availableThemes: this.themes && this.themes.length > 0 ? this.themes.map(t => t.name) : ['light', 'dark'], availableLocales: this.availableLocaleOptions, settings: this.currentSettingsConfig, terms: this.resolvedTerms, onSettingsChange: event => this.handleSettingsChange(event) }), index.h("kritzel-export", { key: '29d02ffd3358fe91d27435cc3fdd5f8ac2ad7cf5', ref: el => {
3247
+ }, shortcuts: this.shortcuts, availableThemes: this.themes && this.themes.length > 0 ? this.themes.map(t => t.name) : ['light', 'dark'], availableLocales: this.availableLocaleOptions, settings: this.currentSettingsConfig, terms: this.resolvedTerms, onSettingsChange: event => this.handleSettingsChange(event) }), index.h("kritzel-export", { key: 'cad9614f0e439713e96108476ed3c400c14546cc', ref: el => {
3200
3248
  if (el) {
3201
3249
  this.exportRef = el;
3202
3250
  }
3203
- }, workspaceName: this.activeWorkspace?.name || 'workspace', terms: this.resolvedTerms, onExportPng: () => this.engineRef.exportViewportAsPng(), onExportSvg: () => this.engineRef.exportViewportAsSvg(), onExportJson: event => this.engineRef.downloadAsJson(event.detail) }), index.h("kritzel-active-users", { key: '729ca599e46654bc1429b5d91ff73f26d9b6e7bb', users: this.activeUsers }), shouldShowCurrentUser && index.h("kritzel-current-user", { key: '84c07268072418a82cf504f16803c59a1e37c0ae', user: this.user, terms: this.resolvedTerms, onClick: () => this.currentUserDialogRef?.open() }), shouldShowCurrentUser && (index.h("kritzel-current-user-dialog", { key: '55189e3da7899628b252e7995b20bc422fce2d5c', ref: el => {
3251
+ }, workspaceName: this.activeWorkspace?.name || 'workspace', terms: this.resolvedTerms, onExportPng: () => this.engineRef.exportViewportAsPng(), onExportSvg: () => this.engineRef.exportViewportAsSvg(), onExportJson: event => this.engineRef.downloadAsJson(event.detail) }), index.h("kritzel-active-users", { key: '191775e6c3f62391e4c62cb8039ace51b4853555', users: this.activeUsers }), shouldShowCurrentUser && index.h("kritzel-current-user", { key: '385264c0ebb74eb1c3e3793ad889b8bd84ec1920', user: this.user, terms: this.resolvedTerms, onClick: () => this.currentUserDialogRef?.open() }), shouldShowCurrentUser && (index.h("kritzel-current-user-dialog", { key: '065b583dd31019fe88739171089414123af7d640', ref: el => {
3204
3252
  if (el) {
3205
3253
  this.currentUserDialogRef = el;
3206
3254
  this.currentUserDialogRef.addEventListener('logoutRequest', this.handleCurrentUserLogout);
3207
3255
  }
3208
- }, user: this.user, terms: this.resolvedTerms })), shouldShowLoginButton && (index.h("kritzel-button", { key: 'a55d85769bad635fcc7934f584bf1bf74c1e7ea6', onButtonClick: () => this.loginDialogRef?.open() }, this.resolvedTerms['login.dialogTitle'] ?? 'Sign in')), index.h("kritzel-more-menu", { key: '67dd879ec3c55c98e32f6112ca53d925f47b56b4', items: this.moreMenuItems, visible: this.isMoreMenuVisible, terms: this.resolvedTerms }), index.h("kritzel-share-dialog", { key: '5f13130099110eb472022099219e15877ceceb57', ref: el => {
3256
+ }, user: this.user, terms: this.resolvedTerms })), shouldShowLoginButton && index.h("kritzel-button", { key: 'b655001273147d58f299983c9512d10ee16847a1', onButtonClick: () => this.loginDialogRef?.open() }, this.resolvedTerms['login.dialogTitle'] ?? 'Sign in'), index.h("kritzel-more-menu", { key: '265cd05a2e62fb3a16beae85a82a81e69410f284', items: this.resolvedMoreMenuItems, visible: this.isMoreMenuVisible, terms: this.resolvedTerms }), index.h("kritzel-share-dialog", { key: '0c441b88aab039e4f9eb86d226670732f0635446', ref: el => {
3209
3257
  if (el) {
3210
3258
  this.shareDialogRef = el;
3211
3259
  }
3212
- }, isPublic: this.currentIsPublic, workspaceId: this.activeWorkspace?.id, terms: this.resolvedTerms, onToggleIsPublic: this.handleToggleIsPublic }), this.loginConfig && (index.h("kritzel-login-dialog", { key: 'cda0fcdcad75de8730ce10bda425cc0fbe01429a', ref: el => {
3260
+ }, isPublic: this.currentIsPublic, workspaceId: this.activeWorkspace?.id, terms: this.resolvedTerms, onToggleIsPublic: this.handleToggleIsPublic }), this.loginConfig && (index.h("kritzel-login-dialog", { key: '3e06e1b33c3dc98cfa8f76641acdb5a3ea51c1d5', ref: el => {
3213
3261
  if (el) {
3214
3262
  this.loginDialogRef = el;
3215
3263
  }
@@ -3234,6 +3282,18 @@ const KritzelEditor = class {
3234
3282
  "controls": [{
3235
3283
  "onControlsChange": 0
3236
3284
  }],
3285
+ "moreMenuItems": [{
3286
+ "onMoreMenuItemsChange": 0
3287
+ }],
3288
+ "syncConfig": [{
3289
+ "onSyncConfigChange": 0
3290
+ }],
3291
+ "loginConfig": [{
3292
+ "onLoginConfigChange": 0
3293
+ }],
3294
+ "user": [{
3295
+ "onUserChange": 0
3296
+ }],
3237
3297
  "activeWorkspaceId": [{
3238
3298
  "onActiveWorkspaceIdChange": 0
3239
3299
  }],
@@ -25940,952 +26000,310 @@ class KritzelCore {
25940
26000
  }
25941
26001
  }
25942
26002
 
25943
- /**
25944
- * Helper class for exporting Kritzel objects to SVG format.
25945
- * Generates clean, standards-compliant SVG markup from canvas objects.
25946
- */
25947
- class KritzelSvgExportHelper {
25948
- /**
25949
- * Calculates the combined bounding box that encompasses all objects.
25950
- * Uses rotatedBoundingBox to account for object rotation.
25951
- * @param objects - Array of objects to calculate bounds for
25952
- * @returns Combined bounding box, or null if no objects provided
25953
- */
25954
- static calculateCombinedBounds(objects) {
25955
- if (!objects || objects.length === 0) {
25956
- return null;
25957
- }
25958
- let minX = Infinity;
25959
- let minY = Infinity;
25960
- let maxX = -Infinity;
25961
- let maxY = -Infinity;
25962
- for (const obj of objects) {
25963
- const bbox = obj.rotatedBoundingBox;
25964
- minX = Math.min(minX, bbox.x);
25965
- minY = Math.min(minY, bbox.y);
25966
- maxX = Math.max(maxX, bbox.x + bbox.width);
25967
- maxY = Math.max(maxY, bbox.y + bbox.height);
25968
- }
25969
- return {
25970
- x: minX,
25971
- y: minY,
25972
- z: 1,
25973
- width: maxX - minX,
25974
- height: maxY - minY,
25975
- };
26003
+ function resolveUrl(url, baseUrl) {
26004
+ // url is absolute already
26005
+ if (url.match(/^[a-z]+:\/\//i)) {
26006
+ return url;
25976
26007
  }
25977
- /**
25978
- * Generates a complete SVG document string from the given objects.
25979
- * @param objects - Objects to export
25980
- * @param options - Export options
25981
- * @returns SVG document string
25982
- */
25983
- static generateSvg(objects, options = {}) {
25984
- const { theme, padding = 0, includeXmlDeclaration = true, rasterSafeForCanvas = false, imageDataUrls } = options;
25985
- if (!objects || objects.length === 0) {
25986
- return '';
25987
- }
25988
- // Flatten groups to get all objects including nested children
25989
- const allObjects = this.flattenObjects(objects);
25990
- const bounds = this.calculateCombinedBounds(allObjects);
25991
- if (!bounds) {
25992
- return '';
26008
+ // url is absolute already, without protocol
26009
+ if (url.match(/^\/\//)) {
26010
+ return window.location.protocol + url;
26011
+ }
26012
+ // dataURI, mailto:, tel:, etc.
26013
+ if (url.match(/^[a-z]+:/i)) {
26014
+ return url;
26015
+ }
26016
+ const doc = document.implementation.createHTMLDocument();
26017
+ const base = doc.createElement('base');
26018
+ const a = doc.createElement('a');
26019
+ doc.head.appendChild(base);
26020
+ doc.body.appendChild(a);
26021
+ if (baseUrl) {
26022
+ base.href = baseUrl;
26023
+ }
26024
+ a.href = url;
26025
+ return a.href;
26026
+ }
26027
+ const uuid = (() => {
26028
+ // generate uuid for className of pseudo elements.
26029
+ // We should not use GUIDs, otherwise pseudo elements sometimes cannot be captured.
26030
+ let counter = 0;
26031
+ // ref: http://stackoverflow.com/a/6248722/2519373
26032
+ const random = () =>
26033
+ // eslint-disable-next-line no-bitwise
26034
+ `0000${((Math.random() * 36 ** 4) << 0).toString(36)}`.slice(-4);
26035
+ return () => {
26036
+ counter += 1;
26037
+ return `u${random()}${counter}`;
26038
+ };
26039
+ })();
26040
+ function toArray(arrayLike) {
26041
+ const arr = [];
26042
+ for (let i = 0, l = arrayLike.length; i < l; i++) {
26043
+ arr.push(arrayLike[i]);
26044
+ }
26045
+ return arr;
26046
+ }
26047
+ let styleProps = null;
26048
+ function getStyleProperties(options = {}) {
26049
+ if (styleProps) {
26050
+ return styleProps;
26051
+ }
26052
+ if (options.includeStyleProperties) {
26053
+ styleProps = options.includeStyleProperties;
26054
+ return styleProps;
26055
+ }
26056
+ styleProps = toArray(window.getComputedStyle(document.documentElement));
26057
+ return styleProps;
26058
+ }
26059
+ function px(node, styleProperty) {
26060
+ const win = node.ownerDocument.defaultView || window;
26061
+ const val = win.getComputedStyle(node).getPropertyValue(styleProperty);
26062
+ return val ? parseFloat(val.replace('px', '')) : 0;
26063
+ }
26064
+ function getNodeWidth(node) {
26065
+ const leftBorder = px(node, 'border-left-width');
26066
+ const rightBorder = px(node, 'border-right-width');
26067
+ return node.clientWidth + leftBorder + rightBorder;
26068
+ }
26069
+ function getNodeHeight(node) {
26070
+ const topBorder = px(node, 'border-top-width');
26071
+ const bottomBorder = px(node, 'border-bottom-width');
26072
+ return node.clientHeight + topBorder + bottomBorder;
26073
+ }
26074
+ function getImageSize(targetNode, options = {}) {
26075
+ const width = options.width || getNodeWidth(targetNode);
26076
+ const height = options.height || getNodeHeight(targetNode);
26077
+ return { width, height };
26078
+ }
26079
+ function getPixelRatio() {
26080
+ let ratio;
26081
+ let FINAL_PROCESS;
26082
+ try {
26083
+ FINAL_PROCESS = process;
26084
+ }
26085
+ catch (e) {
26086
+ // pass
26087
+ }
26088
+ const val = FINAL_PROCESS && FINAL_PROCESS.env
26089
+ ? FINAL_PROCESS.env.devicePixelRatio
26090
+ : null;
26091
+ if (val) {
26092
+ ratio = parseInt(val, 10);
26093
+ if (Number.isNaN(ratio)) {
26094
+ ratio = 1;
25993
26095
  }
25994
- // Add padding to bounds
25995
- const viewBoxX = bounds.x - padding;
25996
- const viewBoxY = bounds.y - padding;
25997
- const viewBoxWidth = bounds.width + padding * 2;
25998
- const viewBoxHeight = bounds.height + padding * 2;
25999
- // Collect all defs (markers, patterns, etc.)
26000
- const defs = this.collectDefs(allObjects, theme);
26001
- // Generate SVG elements for each object
26002
- const elements = objects
26003
- .sort((a, b) => a.zIndex - b.zIndex)
26004
- .map(obj => this.objectToSvgElement(obj, theme, imageDataUrls, rasterSafeForCanvas))
26005
- .filter(Boolean)
26006
- .join('\n ');
26007
- const xmlDecl = includeXmlDeclaration ? '<?xml version="1.0" encoding="UTF-8"?>\n' : '';
26008
- const defsContent = defs.length > 0 ? `\n <defs>\n ${defs.join('\n ')}\n </defs>` : '';
26009
- return `${xmlDecl}<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBoxX} ${viewBoxY} ${viewBoxWidth} ${viewBoxHeight}" width="${viewBoxWidth}" height="${viewBoxHeight}">${defsContent}
26010
- ${elements}
26011
- </svg>`;
26012
26096
  }
26013
- /**
26014
- * Flattens a list of objects, expanding groups into their children recursively.
26015
- * @param objects - Objects to flatten
26016
- * @returns Flat array of all objects including group children
26017
- */
26018
- static flattenObjects(objects) {
26019
- const result = [];
26020
- for (const obj of objects) {
26021
- if (schema_constants.KritzelClassHelper.isInstanceOf(obj, 'KritzelGroup')) {
26022
- result.push(...this.flattenObjects(obj.children));
26097
+ return ratio || window.devicePixelRatio || 1;
26098
+ }
26099
+ // @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#maximum_canvas_size
26100
+ const canvasDimensionLimit = 16384;
26101
+ function checkCanvasDimensions(canvas) {
26102
+ if (canvas.width > canvasDimensionLimit ||
26103
+ canvas.height > canvasDimensionLimit) {
26104
+ if (canvas.width > canvasDimensionLimit &&
26105
+ canvas.height > canvasDimensionLimit) {
26106
+ if (canvas.width > canvas.height) {
26107
+ canvas.height *= canvasDimensionLimit / canvas.width;
26108
+ canvas.width = canvasDimensionLimit;
26023
26109
  }
26024
26110
  else {
26025
- result.push(obj);
26111
+ canvas.width *= canvasDimensionLimit / canvas.height;
26112
+ canvas.height = canvasDimensionLimit;
26026
26113
  }
26027
26114
  }
26028
- return result;
26029
- }
26030
- /**
26031
- * Resolves every image referenced by the given objects (including those
26032
- * nested inside groups) to an inlined `data:` URL, keyed by object id.
26033
- *
26034
- * This is required for export flows that rasterize the SVG (e.g. PNG
26035
- * export): an SVG loaded into an `<img>` element will NOT load external
26036
- * or `blob:` resources, so a `<image href="blob:...">` renders as a
26037
- * broken-image placeholder. Embedding the bytes as a base64 `data:` URL
26038
- * makes the image self-contained and survive rasterization.
26039
- *
26040
- * Resolution is best-effort: images that fail to resolve are simply
26041
- * omitted from the map, and the SVG falls back to whatever `href` the
26042
- * synchronous path would have produced.
26043
- * @param objects - Objects to inline images for
26044
- * @param assetResolver - Resolver used to fetch asset bytes; optional
26045
- * @returns Map of image object id -> inlined `data:` URL
26046
- */
26047
- static async resolveImageDataUrls(objects, assetResolver) {
26048
- const dataUrls = new Map();
26049
- if (!objects || objects.length === 0) {
26050
- return dataUrls;
26115
+ else if (canvas.width > canvasDimensionLimit) {
26116
+ canvas.height *= canvasDimensionLimit / canvas.width;
26117
+ canvas.width = canvasDimensionLimit;
26051
26118
  }
26052
- const images = this.flattenObjects(objects).filter((obj) => schema_constants.KritzelClassHelper.isInstanceOf(obj, 'KritzelImage'));
26053
- await Promise.all(images.map(async (image) => {
26054
- // Legacy inline data URL is already embeddable as-is.
26055
- if (image.src?.startsWith('data:')) {
26056
- dataUrls.set(image.id, image.src);
26057
- return;
26058
- }
26059
- // A resolvedSrc that is itself a data URL can be embedded directly.
26060
- if (image.resolvedSrc?.startsWith('data:')) {
26061
- dataUrls.set(image.id, image.resolvedSrc);
26062
- return;
26063
- }
26064
- if (!image.assetId || !assetResolver) {
26065
- return;
26066
- }
26119
+ else {
26120
+ canvas.width *= canvasDimensionLimit / canvas.height;
26121
+ canvas.height = canvasDimensionLimit;
26122
+ }
26123
+ }
26124
+ }
26125
+ function createImage(url) {
26126
+ return new Promise((resolve, reject) => {
26127
+ const img = new Image();
26128
+ img.onload = () => {
26129
+ img.decode().then(() => {
26130
+ requestAnimationFrame(() => resolve(img));
26131
+ });
26132
+ };
26133
+ img.onerror = reject;
26134
+ img.crossOrigin = 'anonymous';
26135
+ img.decoding = 'async';
26136
+ img.src = url;
26137
+ });
26138
+ }
26139
+ async function svgToDataURL(svg) {
26140
+ return Promise.resolve()
26141
+ .then(() => new XMLSerializer().serializeToString(svg))
26142
+ .then(encodeURIComponent)
26143
+ .then((html) => `data:image/svg+xml;charset=utf-8,${html}`);
26144
+ }
26145
+ async function nodeToDataURL(node, width, height) {
26146
+ const xmlns = 'http://www.w3.org/2000/svg';
26147
+ const svg = document.createElementNS(xmlns, 'svg');
26148
+ const foreignObject = document.createElementNS(xmlns, 'foreignObject');
26149
+ svg.setAttribute('width', `${width}`);
26150
+ svg.setAttribute('height', `${height}`);
26151
+ svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
26152
+ foreignObject.setAttribute('width', '100%');
26153
+ foreignObject.setAttribute('height', '100%');
26154
+ foreignObject.setAttribute('x', '0');
26155
+ foreignObject.setAttribute('y', '0');
26156
+ foreignObject.setAttribute('externalResourcesRequired', 'true');
26157
+ svg.appendChild(foreignObject);
26158
+ foreignObject.appendChild(node);
26159
+ return svgToDataURL(svg);
26160
+ }
26161
+ const isInstanceOfElement = (node, instance) => {
26162
+ if (node instanceof instance)
26163
+ return true;
26164
+ const nodePrototype = Object.getPrototypeOf(node);
26165
+ if (nodePrototype === null)
26166
+ return false;
26167
+ return (nodePrototype.constructor.name === instance.name ||
26168
+ isInstanceOfElement(nodePrototype, instance));
26169
+ };
26170
+
26171
+ function formatCSSText(style) {
26172
+ const content = style.getPropertyValue('content');
26173
+ return `${style.cssText} content: '${content.replace(/'|"/g, '')}';`;
26174
+ }
26175
+ function formatCSSProperties(style, options) {
26176
+ return getStyleProperties(options)
26177
+ .map((name) => {
26178
+ const value = style.getPropertyValue(name);
26179
+ const priority = style.getPropertyPriority(name);
26180
+ return `${name}: ${value}${priority ? ' !important' : ''};`;
26181
+ })
26182
+ .join(' ');
26183
+ }
26184
+ function getPseudoElementStyle(className, pseudo, style, options) {
26185
+ const selector = `.${className}:${pseudo}`;
26186
+ const cssText = style.cssText
26187
+ ? formatCSSText(style)
26188
+ : formatCSSProperties(style, options);
26189
+ return document.createTextNode(`${selector}{${cssText}}`);
26190
+ }
26191
+ function clonePseudoElement(nativeNode, clonedNode, pseudo, options) {
26192
+ const style = window.getComputedStyle(nativeNode, pseudo);
26193
+ const content = style.getPropertyValue('content');
26194
+ if (content === '' || content === 'none') {
26195
+ return;
26196
+ }
26197
+ const className = uuid();
26198
+ try {
26199
+ clonedNode.className = `${clonedNode.className} ${className}`;
26200
+ }
26201
+ catch (err) {
26202
+ return;
26203
+ }
26204
+ const styleElement = document.createElement('style');
26205
+ styleElement.appendChild(getPseudoElementStyle(className, pseudo, style, options));
26206
+ clonedNode.appendChild(styleElement);
26207
+ }
26208
+ function clonePseudoElements(nativeNode, clonedNode, options) {
26209
+ clonePseudoElement(nativeNode, clonedNode, ':before', options);
26210
+ clonePseudoElement(nativeNode, clonedNode, ':after', options);
26211
+ }
26212
+
26213
+ const WOFF = 'application/font-woff';
26214
+ const JPEG = 'image/jpeg';
26215
+ const mimes = {
26216
+ woff: WOFF,
26217
+ woff2: WOFF,
26218
+ ttf: 'application/font-truetype',
26219
+ eot: 'application/vnd.ms-fontobject',
26220
+ png: 'image/png',
26221
+ jpg: JPEG,
26222
+ jpeg: JPEG,
26223
+ gif: 'image/gif',
26224
+ tiff: 'image/tiff',
26225
+ svg: 'image/svg+xml',
26226
+ webp: 'image/webp',
26227
+ };
26228
+ function getExtension(url) {
26229
+ const match = /\.([^./]*?)$/g.exec(url);
26230
+ return match ? match[1] : '';
26231
+ }
26232
+ function getMimeType(url) {
26233
+ const extension = getExtension(url).toLowerCase();
26234
+ return mimes[extension] || '';
26235
+ }
26236
+
26237
+ function getContentFromDataUrl(dataURL) {
26238
+ return dataURL.split(/,/)[1];
26239
+ }
26240
+ function isDataUrl(url) {
26241
+ return url.search(/^(data:)/) !== -1;
26242
+ }
26243
+ function makeDataUrl(content, mimeType) {
26244
+ return `data:${mimeType};base64,${content}`;
26245
+ }
26246
+ async function fetchAsDataURL(url, init, process) {
26247
+ const res = await fetch(url, init);
26248
+ if (res.status === 404) {
26249
+ throw new Error(`Resource "${res.url}" not found`);
26250
+ }
26251
+ const blob = await res.blob();
26252
+ return new Promise((resolve, reject) => {
26253
+ const reader = new FileReader();
26254
+ reader.onerror = reject;
26255
+ reader.onloadend = () => {
26067
26256
  try {
26068
- const blob = await assetResolver.fetchBlob(image.assetId);
26069
- dataUrls.set(image.id, await this.blobToDataUrl(blob));
26257
+ resolve(process({ res, result: reader.result }));
26070
26258
  }
26071
- catch (err) {
26072
- console.warn(`[KritzelSvgExportHelper] Failed to inline image asset ${image.assetId} for export:`, err);
26259
+ catch (error) {
26260
+ reject(error);
26073
26261
  }
26074
- }));
26075
- return dataUrls;
26262
+ };
26263
+ reader.readAsDataURL(blob);
26264
+ });
26265
+ }
26266
+ const cache = {};
26267
+ function getCacheKey(url, contentType, includeQueryParams) {
26268
+ let key = url.replace(/\?.*/, '');
26269
+ if (includeQueryParams) {
26270
+ key = url;
26076
26271
  }
26077
- /**
26078
- * Reads a Blob into a base64 `data:` URL.
26079
- * @param blob - The blob to encode
26080
- * @returns Promise resolving to the data URL string
26081
- */
26082
- static async blobToDataUrl(blob) {
26083
- // Prefer FileReader in browser-like runtimes for efficiency.
26084
- const hasFileReader = typeof FileReader !== 'undefined';
26085
- if (hasFileReader) {
26086
- try {
26087
- return await new Promise((resolve, reject) => {
26088
- const reader = new FileReader();
26089
- reader.onload = () => {
26090
- if (typeof reader.result === 'string') {
26091
- resolve(reader.result);
26092
- return;
26093
- }
26094
- reject(new Error('FileReader result is not a string'));
26095
- };
26096
- reader.onerror = () => reject(reader.error ?? new Error('Failed to read blob as data URL'));
26097
- reader.readAsDataURL(blob);
26098
- });
26099
- }
26100
- catch {
26101
- // Fall through to the ArrayBuffer-based encoder.
26272
+ // font resource
26273
+ if (/ttf|otf|eot|woff2?/i.test(key)) {
26274
+ key = key.replace(/.*\//, '');
26275
+ }
26276
+ return contentType ? `[${contentType}]${key}` : key;
26277
+ }
26278
+ async function resourceToDataURL(resourceUrl, contentType, options) {
26279
+ const cacheKey = getCacheKey(resourceUrl, contentType, options.includeQueryParams);
26280
+ if (cache[cacheKey] != null) {
26281
+ return cache[cacheKey];
26282
+ }
26283
+ // ref: https://developer.mozilla.org/en/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest#Bypassing_the_cache
26284
+ if (options.cacheBust) {
26285
+ // eslint-disable-next-line no-param-reassign
26286
+ resourceUrl += (/\?/.test(resourceUrl) ? '&' : '?') + new Date().getTime();
26287
+ }
26288
+ let dataURL;
26289
+ try {
26290
+ const content = await fetchAsDataURL(resourceUrl, options.fetchRequestInit, ({ res, result }) => {
26291
+ if (!contentType) {
26292
+ // eslint-disable-next-line no-param-reassign
26293
+ contentType = res.headers.get('Content-Type') || '';
26102
26294
  }
26103
- }
26104
- const bytes = new Uint8Array(await blob.arrayBuffer());
26105
- const mimeType = blob.type || 'application/octet-stream';
26106
- return `data:${mimeType};base64,${this.uint8ToBase64(bytes)}`;
26295
+ return getContentFromDataUrl(result);
26296
+ });
26297
+ dataURL = makeDataUrl(content, contentType);
26107
26298
  }
26108
- /**
26109
- * Encodes a byte array to base64 across browser and Node-like runtimes.
26110
- */
26111
- static uint8ToBase64(bytes) {
26112
- if (typeof Buffer !== 'undefined') {
26113
- return Buffer.from(bytes).toString('base64');
26299
+ catch (error) {
26300
+ dataURL = options.imagePlaceholder || '';
26301
+ let msg = `Failed to fetch resource: ${resourceUrl}`;
26302
+ if (error) {
26303
+ msg = typeof error === 'string' ? error : error.message;
26114
26304
  }
26115
- let binary = '';
26116
- const chunkSize = 0x8000;
26117
- for (let i = 0; i < bytes.length; i += chunkSize) {
26118
- const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length));
26119
- binary += String.fromCharCode(...chunk);
26120
- }
26121
- if (typeof btoa === 'function') {
26122
- return btoa(binary);
26123
- }
26124
- throw new Error('No base64 encoder available in this runtime');
26125
- }
26126
- /**
26127
- * Collects SVG defs (markers, patterns, etc.) needed by the objects.
26128
- * @param objects - Objects to collect defs for
26129
- * @param theme - Theme for color resolution
26130
- * @returns Array of def element strings
26131
- */
26132
- static collectDefs(objects, theme) {
26133
- const defs = [];
26134
- for (const obj of objects) {
26135
- if (schema_constants.KritzelClassHelper.isInstanceOf(obj, 'KritzelLine')) {
26136
- if (obj.hasStartArrow) {
26137
- defs.push(this.generateArrowMarker(obj, 'start', theme));
26138
- }
26139
- if (obj.hasEndArrow) {
26140
- defs.push(this.generateArrowMarker(obj, 'end', theme));
26141
- }
26142
- }
26143
- }
26144
- return defs;
26145
- }
26146
- /**
26147
- * Generates an SVG marker element for line arrows.
26148
- * @param line - The line object
26149
- * @param end - Which end of the line ('start' or 'end')
26150
- * @param theme - Theme for color resolution
26151
- * @returns SVG marker element string
26152
- */
26153
- static generateArrowMarker(line, end, _theme) {
26154
- const markerId = end === 'start' ? line.startMarkerId : line.endMarkerId;
26155
- const size = line.getArrowSize(end);
26156
- const fill = line.getArrowFill(end);
26157
- const style = end === 'start' ? line.arrows?.start?.style : line.arrows?.end?.style;
26158
- const path = line.getArrowPath(style);
26159
- const orient = end === 'start' ? 'auto-start-reverse' : 'auto';
26160
- const refX = 0;
26161
- const refY = size / 2;
26162
- return `<marker id="${markerId}" markerWidth="${size}" markerHeight="${size}" refX="${refX}" refY="${refY}" orient="${orient}" markerUnits="userSpaceOnUse">
26163
- <path d="${path}" fill="${fill}" transform="scale(${size / 10})"/>
26164
- </marker>`;
26165
- }
26166
- /**
26167
- * Converts a Kritzel object to its SVG element representation.
26168
- * @param object - The object to convert
26169
- * @param theme - Theme for color resolution
26170
- * @param imageDataUrls - Optional map of image id -> inlined data URL
26171
- * @returns SVG element string, or empty string if object type is not supported
26172
- */
26173
- static objectToSvgElement(object, theme, imageDataUrls, rasterSafeForCanvas = false) {
26174
- if (schema_constants.KritzelClassHelper.isInstanceOf(object, 'KritzelPath')) {
26175
- return this.pathToSvg(object, theme);
26176
- }
26177
- if (schema_constants.KritzelClassHelper.isInstanceOf(object, 'KritzelLine')) {
26178
- return this.lineToSvg(object, theme);
26179
- }
26180
- if (schema_constants.KritzelClassHelper.isInstanceOf(object, 'KritzelShape')) {
26181
- return this.shapeToSvg(object, theme);
26182
- }
26183
- if (schema_constants.KritzelClassHelper.isInstanceOf(object, 'KritzelText')) {
26184
- return this.textToSvg(object, theme, rasterSafeForCanvas);
26185
- }
26186
- if (schema_constants.KritzelClassHelper.isInstanceOf(object, 'KritzelImage')) {
26187
- return this.imageToSvg(object, imageDataUrls);
26188
- }
26189
- if (schema_constants.KritzelClassHelper.isInstanceOf(object, 'KritzelCustomElement')) {
26190
- return this.customElementToSvg(object, rasterSafeForCanvas);
26191
- }
26192
- if (schema_constants.KritzelClassHelper.isInstanceOf(object, 'KritzelGroup')) {
26193
- return this.groupToSvg(object, theme, imageDataUrls, rasterSafeForCanvas);
26194
- }
26195
- return '';
26196
- }
26197
- /**
26198
- * Converts a KritzelPath to SVG.
26199
- * @param path - The path object
26200
- * @param theme - Theme for color resolution
26201
- * @returns SVG element string
26202
- */
26203
- static pathToSvg(path, theme) {
26204
- const transform = this.buildTransform(path);
26205
- const fill = schema_constants.KritzelColorHelper.resolveThemeColor(path.fill, theme);
26206
- const stroke = schema_constants.KritzelColorHelper.resolveThemeColor(path.stroke, theme);
26207
- const opacity = path.opacity !== 1 ? ` opacity="${path.opacity}"` : '';
26208
- const scale = this.getObjectScale(path);
26209
- // Path needs to be wrapped in a g with translation since path.d uses local coordinates
26210
- return `<g transform="${transform}"${opacity}>
26211
- <svg viewBox="${path.viewBox}" width="${path.totalWidth / scale}" height="${path.totalHeight / scale}" overflow="visible">
26212
- <path d="${path.d}" fill="${fill || 'none'}" stroke="${stroke || 'none'}"${path.strokeWidth ? ` stroke-width="${path.strokeWidth}"` : ''}/>
26213
- </svg>
26214
- </g>`;
26215
- }
26216
- /**
26217
- * Converts a KritzelLine to SVG.
26218
- * @param line - The line object
26219
- * @param theme - Theme for color resolution
26220
- * @returns SVG element string
26221
- */
26222
- static lineToSvg(line, theme) {
26223
- const transform = this.buildTransform(line);
26224
- const stroke = schema_constants.KritzelColorHelper.resolveThemeColor(line.stroke, theme);
26225
- const opacity = line.opacity !== 1 ? ` opacity="${line.opacity}"` : '';
26226
- const scale = this.getObjectScale(line);
26227
- const markerStart = line.hasStartArrow ? ` marker-start="url(#${line.startMarkerId})"` : '';
26228
- const markerEnd = line.hasEndArrow ? ` marker-end="url(#${line.endMarkerId})"` : '';
26229
- return `<g transform="${transform}"${opacity}>
26230
- <svg viewBox="${line.viewBox}" width="${line.totalWidth / scale}" height="${line.totalHeight / scale}" overflow="visible">
26231
- <path d="${line.d}" fill="none" stroke="${stroke}" stroke-width="${line.strokeWidth}" stroke-linecap="round"${markerStart}${markerEnd}/>
26232
- </svg>
26233
- </g>`;
26234
- }
26235
- /**
26236
- * Converts a KritzelShape to SVG.
26237
- * @param shape - The shape object
26238
- * @param theme - Theme for color resolution
26239
- * @returns SVG element string
26240
- */
26241
- static shapeToSvg(shape, theme) {
26242
- const transform = this.buildTransform(shape);
26243
- const fill = schema_constants.KritzelColorHelper.resolveThemeColor(shape.fillColor, theme);
26244
- const stroke = schema_constants.KritzelColorHelper.resolveThemeColor(shape.strokeColor, theme);
26245
- const opacity = shape.opacity !== 1 ? ` opacity="${shape.opacity}"` : '';
26246
- const scale = this.getObjectScale(shape);
26247
- // Get the SVG path for the shape
26248
- const pathD = shape.getSvgPath();
26249
- // Handle text content if present
26250
- let textContent = '';
26251
- if (shape.editor && !shape.editor.state.doc.textContent.trim()) ;
26252
- else if (shape.content) {
26253
- const textHtml = this.prosemirrorToHtml(shape.content, shape, theme);
26254
- if (textHtml) {
26255
- const fontColor = schema_constants.KritzelColorHelper.resolveThemeColor(shape.fontColor, theme);
26256
- textContent = `
26257
- <foreignObject x="${shape.x}" y="${shape.y}" width="${shape.width}" height="${shape.height}">
26258
- <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;">
26259
- ${textHtml}
26260
- </div>
26261
- </foreignObject>`;
26262
- }
26263
- }
26264
- return `<g transform="${transform}"${opacity}>
26265
- <svg viewBox="${shape.viewBox}" width="${shape.totalWidth / scale}" height="${shape.totalHeight / scale}" overflow="visible" preserveAspectRatio="none">
26266
- <path d="${pathD}" fill="${fill || 'transparent'}" stroke="${stroke}" stroke-width="${shape.strokeWidth}"/>${textContent}
26267
- </svg>
26268
- </g>`;
26269
- }
26270
- /**
26271
- * Converts a KritzelText to SVG using foreignObject for rich text support.
26272
- * @param text - The text object
26273
- * @param theme - Theme for color resolution
26274
- * @returns SVG element string
26275
- */
26276
- static textToSvg(text, theme, rasterSafeForCanvas = false) {
26277
- if (rasterSafeForCanvas) {
26278
- return this.textToSvgRasterSafe(text, theme);
26279
- }
26280
- const transform = this.buildTransform(text);
26281
- const opacity = text.opacity !== 1 ? ` opacity="${text.opacity}"` : '';
26282
- const fontColor = schema_constants.KritzelColorHelper.resolveThemeColor(text.fontColor, theme);
26283
- const bgColor = schema_constants.KritzelColorHelper.resolveThemeColor(text.backgroundColor, theme);
26284
- const scale = this.getObjectScale(text);
26285
- // Convert ProseMirror content to HTML
26286
- const htmlContent = this.prosemirrorToHtml(text.content, text, theme);
26287
- // Calculate dimensions accounting for scale
26288
- const width = text.totalWidth / scale;
26289
- const height = text.totalHeight / scale;
26290
- return `<g transform="${transform}"${opacity}>
26291
- <foreignObject x="0" y="0" width="${width}" height="${height}">
26292
- <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;">
26293
- ${htmlContent}
26294
- </div>
26295
- </foreignObject>
26296
- </g>`;
26297
- }
26298
- /**
26299
- * Converts a KritzelText to SVG using pure SVG text primitives.
26300
- * This path avoids `<foreignObject>`, which taints canvases when the
26301
- * generated SVG is rasterized through `<img>` and `drawImage`.
26302
- */
26303
- static textToSvgRasterSafe(text, theme) {
26304
- const transform = this.buildTransform(text);
26305
- const opacity = text.opacity !== 1 ? ` opacity="${text.opacity}"` : '';
26306
- const fontColor = schema_constants.KritzelColorHelper.resolveThemeColor(text.fontColor, theme);
26307
- const bgColor = schema_constants.KritzelColorHelper.resolveThemeColor(text.backgroundColor, theme);
26308
- const scale = this.getObjectScale(text);
26309
- const width = text.totalWidth / scale;
26310
- const height = text.totalHeight / scale;
26311
- const lines = this.prosemirrorToPlainTextLines(text.content);
26312
- const resolvedLines = lines.length > 0 ? lines : [''];
26313
- const resolvedFontSize = Math.max(1, text.fontSize * (text.scaleFactor || 1));
26314
- const lineHeight = resolvedFontSize * 1.2;
26315
- const startY = resolvedFontSize;
26316
- const bgRect = bgColor && bgColor !== 'transparent'
26317
- ? `<rect x="0" y="0" width="${width}" height="${height}" fill="${bgColor}"/>`
26318
- : '';
26319
- const tspans = resolvedLines
26320
- .map((line, index) => {
26321
- const y = startY + index * lineHeight;
26322
- return `<tspan x="0" y="${y}" xml:space="preserve">${this.escapeHtml(line)}</tspan>`;
26323
- })
26324
- .join('');
26325
- return `<g transform="${transform}"${opacity}>
26326
- ${bgRect}
26327
- <text font-family="${this.escapeHtml(text.fontFamily)}" font-size="${resolvedFontSize}" fill="${fontColor}">${tspans}</text>
26328
- </g>`;
26329
- }
26330
- /**
26331
- * Converts a KritzelImage to SVG.
26332
- *
26333
- * Prefers an inlined `data:` URL from `imageDataUrls` (keyed by object
26334
- * id) when provided, so the embedded bytes survive rasterization to
26335
- * PNG. Falls back to `resolvedSrc` (populated by the renderer via the
26336
- * asset resolver) and finally the legacy inline `src` field for
26337
- * documents persisted before the asset layer existed.
26338
- *
26339
- * Note: a `blob:` `resolvedSrc` renders correctly on-canvas but becomes
26340
- * a broken-image placeholder once the SVG is loaded into an `<img>` and
26341
- * drawn to a canvas. Callers that rasterize the SVG must pass
26342
- * `imageDataUrls` (see {@link KritzelSvgExportHelper.resolveImageDataUrls}).
26343
- * @param image - The image object
26344
- * @param imageDataUrls - Optional map of image id -> inlined data URL
26345
- * @returns SVG element string
26346
- */
26347
- static imageToSvg(image, imageDataUrls) {
26348
- const transform = this.buildTransform(image);
26349
- const opacity = image.opacity !== 1 ? ` opacity="${image.opacity}"` : '';
26350
- const scale = this.getObjectScale(image);
26351
- // Calculate dimensions
26352
- const width = image.totalWidth / scale;
26353
- const height = image.totalHeight / scale;
26354
- const href = imageDataUrls?.get(image.id) || image.resolvedSrc || image.src || '';
26355
- return `<g transform="${transform}"${opacity}>
26356
- <image href="${href}" x="0" y="0" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet"/>
26357
- </g>`;
26358
- }
26359
- /**
26360
- * Converts a KritzelCustomElement to SVG.
26361
- * SVG mode: embeds HTML in foreignObject (best-effort).
26362
- * PNG/raster mode: renders placeholder rect with warning.
26363
- * @param obj - The custom element object
26364
- * @param rasterSafeForCanvas - If true, render placeholder (for PNG export)
26365
- * @returns SVG element string
26366
- */
26367
- static customElementToSvg(obj, rasterSafeForCanvas = false) {
26368
- const transform = this.buildTransform(obj);
26369
- const opacity = obj.opacity !== 1 ? ` opacity="${obj.opacity}"` : '';
26370
- const scale = this.getObjectScale(obj);
26371
- const width = obj.totalWidth / scale;
26372
- const height = obj.totalHeight / scale;
26373
- if (rasterSafeForCanvas) {
26374
- // PNG export: render placeholder rect (foreignObject doesn't rasterize to PNG well)
26375
- console.warn('[KritzelCustomElement SVG Export] Custom elements do not serialize well to PNG. Use SVG export or screenshot instead.');
26376
- return `<g transform="${transform}"${opacity}>
26377
- <rect x="0" y="0" width="${width}" height="${height}" fill="#f0f0f0" stroke="#999" stroke-width="1" />
26378
- <text x="${width / 2}" y="${height / 2}" text-anchor="middle" dominant-baseline="middle" font-size="12" fill="#999">[Custom Element]</text>
26379
- </g>`;
26380
- }
26381
- // SVG export: use foreignObject to embed HTML
26382
- const html = obj.element?.outerHTML || '<div style="border:1px dashed #ccc">Empty</div>';
26383
- return `<g transform="${transform}"${opacity}>
26384
- <foreignObject x="0" y="0" width="${width}" height="${height}">
26385
- <div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;overflow:hidden;">
26386
- ${html}
26387
- </div>
26388
- </foreignObject>
26389
- </g>`;
26390
- }
26391
- /**
26392
- * Converts a KritzelGroup to SVG by recursively converting children.
26393
- * @param group - The group object
26394
- * @param theme - Theme for color resolution
26395
- * @param imageDataUrls - Optional map of image id -> inlined data URL
26396
- * @returns SVG element string
26397
- */
26398
- static groupToSvg(group, theme, imageDataUrls, rasterSafeForCanvas = false) {
26399
- const children = group.children
26400
- .sort((a, b) => a.zIndex - b.zIndex)
26401
- .map(child => this.objectToSvgElement(child, theme, imageDataUrls, rasterSafeForCanvas))
26402
- .filter(Boolean)
26403
- .join('\n ');
26404
- if (!children) {
26405
- return '';
26406
- }
26407
- const opacity = group.opacity !== 1 ? ` opacity="${group.opacity}"` : '';
26408
- return `<g${opacity}>
26409
- ${children}
26410
- </g>`;
26411
- }
26412
- /**
26413
- * Builds a transform attribute string for an object.
26414
- * Combines translation and rotation.
26415
- * @param object - The object to build transform for
26416
- * @returns Transform attribute value string
26417
- */
26418
- static buildTransform(object) {
26419
- const transforms = [];
26420
- const scale = this.getObjectScale(object);
26421
- // Translation
26422
- transforms.push(`translate(${object.translateX}, ${object.translateY})`);
26423
- // Rotation around center
26424
- if (object.rotation !== 0) {
26425
- const centerX = object.totalWidth / 2 / scale;
26426
- const centerY = object.totalHeight / 2 / scale;
26427
- const degrees = object.rotation * (180 / Math.PI);
26428
- transforms.push(`rotate(${degrees}, ${centerX}, ${centerY})`);
26429
- }
26430
- return transforms.join(' ');
26431
- }
26432
- /**
26433
- * Returns a safe non-zero scale for export geometry calculations.
26434
- */
26435
- static getObjectScale(object) {
26436
- const scale = object.scale ?? 1;
26437
- return scale === 0 ? 1 : scale;
26438
- }
26439
- /**
26440
- * Converts ProseMirror JSON content to HTML string.
26441
- * @param content - ProseMirror document JSON
26442
- * @param context - The text or shape object for styling context
26443
- * @param theme - Theme for color resolution
26444
- * @returns HTML string
26445
- */
26446
- static prosemirrorToHtml(content, _context, theme) {
26447
- if (!content || !content.content) {
26448
- return '';
26449
- }
26450
- return content.content
26451
- .map((node) => this.nodeToHtml(node, theme))
26452
- .join('');
26453
- }
26454
- /**
26455
- * Converts a ProseMirror node to HTML.
26456
- * @param node - ProseMirror node JSON
26457
- * @param theme - Theme for color resolution
26458
- * @returns HTML string
26459
- */
26460
- static nodeToHtml(node, theme) {
26461
- if (!node)
26462
- return '';
26463
- switch (node.type) {
26464
- case 'paragraph':
26465
- const pContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
26466
- return `<p style="margin: 0;">${pContent || '<br/>'}</p>`;
26467
- case 'text':
26468
- let text = this.escapeHtml(node.text || '');
26469
- if (node.marks) {
26470
- for (const mark of node.marks) {
26471
- text = this.applyMark(text, mark, theme);
26472
- }
26473
- }
26474
- return text;
26475
- case 'hard_break':
26476
- return '<br/>';
26477
- case 'bullet_list':
26478
- const blContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
26479
- return `<ul style="margin: 0; padding-left: 1.5em;">${blContent}</ul>`;
26480
- case 'ordered_list':
26481
- const olContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
26482
- return `<ol style="margin: 0; padding-left: 1.5em;">${olContent}</ol>`;
26483
- case 'list_item':
26484
- const liContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
26485
- return `<li>${liContent}</li>`;
26486
- default:
26487
- // For unknown node types, try to render content
26488
- if (node.content) {
26489
- return node.content.map((n) => this.nodeToHtml(n, theme)).join('');
26490
- }
26491
- return '';
26492
- }
26493
- }
26494
- /**
26495
- * Converts ProseMirror JSON content to plain-text lines.
26496
- * Used by raster-safe SVG export, which cannot depend on HTML/foreignObject.
26497
- */
26498
- static prosemirrorToPlainTextLines(content) {
26499
- if (!content || !Array.isArray(content.content)) {
26500
- return [];
26501
- }
26502
- const plainText = content.content
26503
- .map((node) => this.nodeToPlainText(node))
26504
- .join('')
26505
- .replace(/\r\n/g, '\n')
26506
- .replace(/\n+$/, '');
26507
- if (!plainText) {
26508
- return [];
26509
- }
26510
- return plainText.split('\n');
26511
- }
26512
- /**
26513
- * Converts a ProseMirror node tree into plain text.
26514
- */
26515
- static nodeToPlainText(node) {
26516
- if (!node) {
26517
- return '';
26518
- }
26519
- switch (node.type) {
26520
- case 'text':
26521
- return node.text || '';
26522
- case 'hard_break':
26523
- return '\n';
26524
- case 'paragraph': {
26525
- const paragraph = Array.isArray(node.content)
26526
- ? node.content.map((child) => this.nodeToPlainText(child)).join('')
26527
- : '';
26528
- return `${paragraph}\n`;
26529
- }
26530
- case 'list_item': {
26531
- const item = Array.isArray(node.content)
26532
- ? node.content.map((child) => this.nodeToPlainText(child)).join('')
26533
- : '';
26534
- return `- ${item.trim()}\n`;
26535
- }
26536
- default:
26537
- return Array.isArray(node.content)
26538
- ? node.content.map((child) => this.nodeToPlainText(child)).join('')
26539
- : '';
26540
- }
26541
- }
26542
- /**
26543
- * Applies a ProseMirror mark to text.
26544
- * @param text - The text to wrap
26545
- * @param mark - The mark to apply
26546
- * @param theme - Theme for color resolution
26547
- * @returns Wrapped HTML string
26548
- */
26549
- static applyMark(text, mark, _theme) {
26550
- switch (mark.type) {
26551
- case 'bold':
26552
- case 'strong':
26553
- return `<strong>${text}</strong>`;
26554
- case 'italic':
26555
- case 'em':
26556
- return `<em>${text}</em>`;
26557
- case 'underline':
26558
- return `<u>${text}</u>`;
26559
- case 'strike':
26560
- return `<s>${text}</s>`;
26561
- case 'code':
26562
- return `<code>${text}</code>`;
26563
- case 'link':
26564
- const href = mark.attrs?.href || '#';
26565
- return `<a href="${this.escapeHtml(href)}">${text}</a>`;
26566
- default:
26567
- return text;
26568
- }
26569
- }
26570
- /**
26571
- * Escapes HTML special characters.
26572
- * @param str - String to escape
26573
- * @returns Escaped string
26574
- */
26575
- static escapeHtml(str) {
26576
- return str
26577
- .replace(/&/g, '&amp;')
26578
- .replace(/</g, '&lt;')
26579
- .replace(/>/g, '&gt;')
26580
- .replace(/"/g, '&quot;')
26581
- .replace(/'/g, '&#039;');
26582
- }
26583
- }
26584
-
26585
- function resolveUrl(url, baseUrl) {
26586
- // url is absolute already
26587
- if (url.match(/^[a-z]+:\/\//i)) {
26588
- return url;
26589
- }
26590
- // url is absolute already, without protocol
26591
- if (url.match(/^\/\//)) {
26592
- return window.location.protocol + url;
26593
- }
26594
- // dataURI, mailto:, tel:, etc.
26595
- if (url.match(/^[a-z]+:/i)) {
26596
- return url;
26597
- }
26598
- const doc = document.implementation.createHTMLDocument();
26599
- const base = doc.createElement('base');
26600
- const a = doc.createElement('a');
26601
- doc.head.appendChild(base);
26602
- doc.body.appendChild(a);
26603
- if (baseUrl) {
26604
- base.href = baseUrl;
26605
- }
26606
- a.href = url;
26607
- return a.href;
26608
- }
26609
- const uuid = (() => {
26610
- // generate uuid for className of pseudo elements.
26611
- // We should not use GUIDs, otherwise pseudo elements sometimes cannot be captured.
26612
- let counter = 0;
26613
- // ref: http://stackoverflow.com/a/6248722/2519373
26614
- const random = () =>
26615
- // eslint-disable-next-line no-bitwise
26616
- `0000${((Math.random() * 36 ** 4) << 0).toString(36)}`.slice(-4);
26617
- return () => {
26618
- counter += 1;
26619
- return `u${random()}${counter}`;
26620
- };
26621
- })();
26622
- function toArray(arrayLike) {
26623
- const arr = [];
26624
- for (let i = 0, l = arrayLike.length; i < l; i++) {
26625
- arr.push(arrayLike[i]);
26626
- }
26627
- return arr;
26628
- }
26629
- let styleProps = null;
26630
- function getStyleProperties(options = {}) {
26631
- if (styleProps) {
26632
- return styleProps;
26633
- }
26634
- if (options.includeStyleProperties) {
26635
- styleProps = options.includeStyleProperties;
26636
- return styleProps;
26637
- }
26638
- styleProps = toArray(window.getComputedStyle(document.documentElement));
26639
- return styleProps;
26640
- }
26641
- function px(node, styleProperty) {
26642
- const win = node.ownerDocument.defaultView || window;
26643
- const val = win.getComputedStyle(node).getPropertyValue(styleProperty);
26644
- return val ? parseFloat(val.replace('px', '')) : 0;
26645
- }
26646
- function getNodeWidth(node) {
26647
- const leftBorder = px(node, 'border-left-width');
26648
- const rightBorder = px(node, 'border-right-width');
26649
- return node.clientWidth + leftBorder + rightBorder;
26650
- }
26651
- function getNodeHeight(node) {
26652
- const topBorder = px(node, 'border-top-width');
26653
- const bottomBorder = px(node, 'border-bottom-width');
26654
- return node.clientHeight + topBorder + bottomBorder;
26655
- }
26656
- function getImageSize(targetNode, options = {}) {
26657
- const width = options.width || getNodeWidth(targetNode);
26658
- const height = options.height || getNodeHeight(targetNode);
26659
- return { width, height };
26660
- }
26661
- function getPixelRatio() {
26662
- let ratio;
26663
- let FINAL_PROCESS;
26664
- try {
26665
- FINAL_PROCESS = process;
26666
- }
26667
- catch (e) {
26668
- // pass
26669
- }
26670
- const val = FINAL_PROCESS && FINAL_PROCESS.env
26671
- ? FINAL_PROCESS.env.devicePixelRatio
26672
- : null;
26673
- if (val) {
26674
- ratio = parseInt(val, 10);
26675
- if (Number.isNaN(ratio)) {
26676
- ratio = 1;
26677
- }
26678
- }
26679
- return ratio || window.devicePixelRatio || 1;
26680
- }
26681
- // @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#maximum_canvas_size
26682
- const canvasDimensionLimit = 16384;
26683
- function checkCanvasDimensions(canvas) {
26684
- if (canvas.width > canvasDimensionLimit ||
26685
- canvas.height > canvasDimensionLimit) {
26686
- if (canvas.width > canvasDimensionLimit &&
26687
- canvas.height > canvasDimensionLimit) {
26688
- if (canvas.width > canvas.height) {
26689
- canvas.height *= canvasDimensionLimit / canvas.width;
26690
- canvas.width = canvasDimensionLimit;
26691
- }
26692
- else {
26693
- canvas.width *= canvasDimensionLimit / canvas.height;
26694
- canvas.height = canvasDimensionLimit;
26695
- }
26696
- }
26697
- else if (canvas.width > canvasDimensionLimit) {
26698
- canvas.height *= canvasDimensionLimit / canvas.width;
26699
- canvas.width = canvasDimensionLimit;
26700
- }
26701
- else {
26702
- canvas.width *= canvasDimensionLimit / canvas.height;
26703
- canvas.height = canvasDimensionLimit;
26704
- }
26705
- }
26706
- }
26707
- function createImage(url) {
26708
- return new Promise((resolve, reject) => {
26709
- const img = new Image();
26710
- img.onload = () => {
26711
- img.decode().then(() => {
26712
- requestAnimationFrame(() => resolve(img));
26713
- });
26714
- };
26715
- img.onerror = reject;
26716
- img.crossOrigin = 'anonymous';
26717
- img.decoding = 'async';
26718
- img.src = url;
26719
- });
26720
- }
26721
- async function svgToDataURL(svg) {
26722
- return Promise.resolve()
26723
- .then(() => new XMLSerializer().serializeToString(svg))
26724
- .then(encodeURIComponent)
26725
- .then((html) => `data:image/svg+xml;charset=utf-8,${html}`);
26726
- }
26727
- async function nodeToDataURL(node, width, height) {
26728
- const xmlns = 'http://www.w3.org/2000/svg';
26729
- const svg = document.createElementNS(xmlns, 'svg');
26730
- const foreignObject = document.createElementNS(xmlns, 'foreignObject');
26731
- svg.setAttribute('width', `${width}`);
26732
- svg.setAttribute('height', `${height}`);
26733
- svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
26734
- foreignObject.setAttribute('width', '100%');
26735
- foreignObject.setAttribute('height', '100%');
26736
- foreignObject.setAttribute('x', '0');
26737
- foreignObject.setAttribute('y', '0');
26738
- foreignObject.setAttribute('externalResourcesRequired', 'true');
26739
- svg.appendChild(foreignObject);
26740
- foreignObject.appendChild(node);
26741
- return svgToDataURL(svg);
26742
- }
26743
- const isInstanceOfElement = (node, instance) => {
26744
- if (node instanceof instance)
26745
- return true;
26746
- const nodePrototype = Object.getPrototypeOf(node);
26747
- if (nodePrototype === null)
26748
- return false;
26749
- return (nodePrototype.constructor.name === instance.name ||
26750
- isInstanceOfElement(nodePrototype, instance));
26751
- };
26752
-
26753
- function formatCSSText(style) {
26754
- const content = style.getPropertyValue('content');
26755
- return `${style.cssText} content: '${content.replace(/'|"/g, '')}';`;
26756
- }
26757
- function formatCSSProperties(style, options) {
26758
- return getStyleProperties(options)
26759
- .map((name) => {
26760
- const value = style.getPropertyValue(name);
26761
- const priority = style.getPropertyPriority(name);
26762
- return `${name}: ${value}${priority ? ' !important' : ''};`;
26763
- })
26764
- .join(' ');
26765
- }
26766
- function getPseudoElementStyle(className, pseudo, style, options) {
26767
- const selector = `.${className}:${pseudo}`;
26768
- const cssText = style.cssText
26769
- ? formatCSSText(style)
26770
- : formatCSSProperties(style, options);
26771
- return document.createTextNode(`${selector}{${cssText}}`);
26772
- }
26773
- function clonePseudoElement(nativeNode, clonedNode, pseudo, options) {
26774
- const style = window.getComputedStyle(nativeNode, pseudo);
26775
- const content = style.getPropertyValue('content');
26776
- if (content === '' || content === 'none') {
26777
- return;
26778
- }
26779
- const className = uuid();
26780
- try {
26781
- clonedNode.className = `${clonedNode.className} ${className}`;
26782
- }
26783
- catch (err) {
26784
- return;
26785
- }
26786
- const styleElement = document.createElement('style');
26787
- styleElement.appendChild(getPseudoElementStyle(className, pseudo, style, options));
26788
- clonedNode.appendChild(styleElement);
26789
- }
26790
- function clonePseudoElements(nativeNode, clonedNode, options) {
26791
- clonePseudoElement(nativeNode, clonedNode, ':before', options);
26792
- clonePseudoElement(nativeNode, clonedNode, ':after', options);
26793
- }
26794
-
26795
- const WOFF = 'application/font-woff';
26796
- const JPEG = 'image/jpeg';
26797
- const mimes = {
26798
- woff: WOFF,
26799
- woff2: WOFF,
26800
- ttf: 'application/font-truetype',
26801
- eot: 'application/vnd.ms-fontobject',
26802
- png: 'image/png',
26803
- jpg: JPEG,
26804
- jpeg: JPEG,
26805
- gif: 'image/gif',
26806
- tiff: 'image/tiff',
26807
- svg: 'image/svg+xml',
26808
- webp: 'image/webp',
26809
- };
26810
- function getExtension(url) {
26811
- const match = /\.([^./]*?)$/g.exec(url);
26812
- return match ? match[1] : '';
26813
- }
26814
- function getMimeType(url) {
26815
- const extension = getExtension(url).toLowerCase();
26816
- return mimes[extension] || '';
26817
- }
26818
-
26819
- function getContentFromDataUrl(dataURL) {
26820
- return dataURL.split(/,/)[1];
26821
- }
26822
- function isDataUrl(url) {
26823
- return url.search(/^(data:)/) !== -1;
26824
- }
26825
- function makeDataUrl(content, mimeType) {
26826
- return `data:${mimeType};base64,${content}`;
26827
- }
26828
- async function fetchAsDataURL(url, init, process) {
26829
- const res = await fetch(url, init);
26830
- if (res.status === 404) {
26831
- throw new Error(`Resource "${res.url}" not found`);
26832
- }
26833
- const blob = await res.blob();
26834
- return new Promise((resolve, reject) => {
26835
- const reader = new FileReader();
26836
- reader.onerror = reject;
26837
- reader.onloadend = () => {
26838
- try {
26839
- resolve(process({ res, result: reader.result }));
26840
- }
26841
- catch (error) {
26842
- reject(error);
26843
- }
26844
- };
26845
- reader.readAsDataURL(blob);
26846
- });
26847
- }
26848
- const cache = {};
26849
- function getCacheKey(url, contentType, includeQueryParams) {
26850
- let key = url.replace(/\?.*/, '');
26851
- if (includeQueryParams) {
26852
- key = url;
26853
- }
26854
- // font resource
26855
- if (/ttf|otf|eot|woff2?/i.test(key)) {
26856
- key = key.replace(/.*\//, '');
26857
- }
26858
- return contentType ? `[${contentType}]${key}` : key;
26859
- }
26860
- async function resourceToDataURL(resourceUrl, contentType, options) {
26861
- const cacheKey = getCacheKey(resourceUrl, contentType, options.includeQueryParams);
26862
- if (cache[cacheKey] != null) {
26863
- return cache[cacheKey];
26864
- }
26865
- // ref: https://developer.mozilla.org/en/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest#Bypassing_the_cache
26866
- if (options.cacheBust) {
26867
- // eslint-disable-next-line no-param-reassign
26868
- resourceUrl += (/\?/.test(resourceUrl) ? '&' : '?') + new Date().getTime();
26869
- }
26870
- let dataURL;
26871
- try {
26872
- const content = await fetchAsDataURL(resourceUrl, options.fetchRequestInit, ({ res, result }) => {
26873
- if (!contentType) {
26874
- // eslint-disable-next-line no-param-reassign
26875
- contentType = res.headers.get('Content-Type') || '';
26876
- }
26877
- return getContentFromDataUrl(result);
26878
- });
26879
- dataURL = makeDataUrl(content, contentType);
26880
- }
26881
- catch (error) {
26882
- dataURL = options.imagePlaceholder || '';
26883
- let msg = `Failed to fetch resource: ${resourceUrl}`;
26884
- if (error) {
26885
- msg = typeof error === 'string' ? error : error.message;
26886
- }
26887
- if (msg) {
26888
- console.warn(msg);
26305
+ if (msg) {
26306
+ console.warn(msg);
26889
26307
  }
26890
26308
  }
26891
26309
  cache[cacheKey] = dataURL;
@@ -27419,156 +26837,808 @@ async function embedWebFonts(clonedNode, options) {
27419
26837
  clonedNode.appendChild(styleNode);
27420
26838
  }
27421
26839
  }
27422
- }
27423
-
27424
- async function toSvg(node, options = {}) {
27425
- const { width, height } = getImageSize(node, options);
27426
- const clonedNode = (await cloneNode(node, options, true));
27427
- await embedWebFonts(clonedNode, options);
27428
- await embedImages(clonedNode, options);
27429
- applyStyle(clonedNode, options);
27430
- const datauri = await nodeToDataURL(clonedNode, width, height);
27431
- return datauri;
27432
- }
27433
- async function toCanvas(node, options = {}) {
27434
- const { width, height } = getImageSize(node, options);
27435
- const svg = await toSvg(node, options);
27436
- const img = await createImage(svg);
27437
- const canvas = document.createElement('canvas');
27438
- const context = canvas.getContext('2d');
27439
- const ratio = options.pixelRatio || getPixelRatio();
27440
- const canvasWidth = options.canvasWidth || width;
27441
- const canvasHeight = options.canvasHeight || height;
27442
- canvas.width = canvasWidth * ratio;
27443
- canvas.height = canvasHeight * ratio;
27444
- if (!options.skipAutoScale) {
27445
- checkCanvasDimensions(canvas);
26840
+ }
26841
+
26842
+ async function toSvg(node, options = {}) {
26843
+ const { width, height } = getImageSize(node, options);
26844
+ const clonedNode = (await cloneNode(node, options, true));
26845
+ await embedWebFonts(clonedNode, options);
26846
+ await embedImages(clonedNode, options);
26847
+ applyStyle(clonedNode, options);
26848
+ const datauri = await nodeToDataURL(clonedNode, width, height);
26849
+ return datauri;
26850
+ }
26851
+ async function toCanvas(node, options = {}) {
26852
+ const { width, height } = getImageSize(node, options);
26853
+ const svg = await toSvg(node, options);
26854
+ const img = await createImage(svg);
26855
+ const canvas = document.createElement('canvas');
26856
+ const context = canvas.getContext('2d');
26857
+ const ratio = options.pixelRatio || getPixelRatio();
26858
+ const canvasWidth = options.canvasWidth || width;
26859
+ const canvasHeight = options.canvasHeight || height;
26860
+ canvas.width = canvasWidth * ratio;
26861
+ canvas.height = canvasHeight * ratio;
26862
+ if (!options.skipAutoScale) {
26863
+ checkCanvasDimensions(canvas);
26864
+ }
26865
+ canvas.style.width = `${canvasWidth}`;
26866
+ canvas.style.height = `${canvasHeight}`;
26867
+ if (options.backgroundColor) {
26868
+ context.fillStyle = options.backgroundColor;
26869
+ context.fillRect(0, 0, canvas.width, canvas.height);
26870
+ }
26871
+ context.drawImage(img, 0, 0, canvas.width, canvas.height);
26872
+ return canvas;
26873
+ }
26874
+ async function toPng(node, options = {}) {
26875
+ const canvas = await toCanvas(node, options);
26876
+ return canvas.toDataURL();
26877
+ }
26878
+
26879
+ class KritzelWheelHelper {
26880
+ static shouldKeepWheelInCustomElement(ev, customElement) {
26881
+ // Keep Ctrl+wheel available for viewport zoom gestures.
26882
+ if (ev.ctrlKey || customElement.isSelected !== true) {
26883
+ return false;
26884
+ }
26885
+ const path = typeof ev.composedPath === 'function' ? ev.composedPath() : [];
26886
+ const scrollContainer = this.getScrollableContainerFromEventPath(path, customElement);
26887
+ // No scroll container found inside the custom element: let the wheel
26888
+ // event pass through so the viewport can continue handling it.
26889
+ if (!scrollContainer) {
26890
+ return false;
26891
+ }
26892
+ return this.canElementConsumeWheel(scrollContainer, ev.deltaX, ev.deltaY);
26893
+ }
26894
+ static getScrollableContainerFromEventPath(path, customElement) {
26895
+ const potentialRoots = [];
26896
+ if (customElement.element instanceof HTMLElement) {
26897
+ potentialRoots.push(customElement.element);
26898
+ }
26899
+ if (customElement.elementRef instanceof HTMLElement) {
26900
+ potentialRoots.push(customElement.elementRef);
26901
+ }
26902
+ const legacyElementRef = customElement.elmeentRef;
26903
+ if (legacyElementRef instanceof HTMLElement) {
26904
+ potentialRoots.push(legacyElementRef);
26905
+ }
26906
+ for (const root of potentialRoots) {
26907
+ const rootIndex = path.findIndex(entry => entry === root);
26908
+ if (rootIndex === -1) {
26909
+ continue;
26910
+ }
26911
+ for (const entry of path.slice(0, rootIndex + 1)) {
26912
+ if (!(entry instanceof HTMLElement)) {
26913
+ continue;
26914
+ }
26915
+ if (this.isScrollableElement(entry)) {
26916
+ return entry;
26917
+ }
26918
+ }
26919
+ }
26920
+ // Fallback for synthetic/custom wheel events where composedPath is absent
26921
+ // or does not include the custom-element subtree.
26922
+ for (const root of potentialRoots) {
26923
+ if (this.isScrollableElement(root)) {
26924
+ return root;
26925
+ }
26926
+ const firstChild = root.firstElementChild;
26927
+ if (firstChild instanceof HTMLElement && this.isScrollableElement(firstChild)) {
26928
+ return firstChild;
26929
+ }
26930
+ }
26931
+ return null;
26932
+ }
26933
+ static isScrollableElement(element) {
26934
+ const canScrollVertically = element.scrollHeight - element.clientHeight > 0;
26935
+ const canScrollHorizontally = element.scrollWidth - element.clientWidth > 0;
26936
+ if (!canScrollVertically && !canScrollHorizontally) {
26937
+ return false;
26938
+ }
26939
+ const computedStyle = window.getComputedStyle(element);
26940
+ const overflowX = computedStyle.overflowX;
26941
+ const overflowY = computedStyle.overflowY;
26942
+ const allowsVerticalScroll = overflowY !== 'visible' && overflowY !== 'clip';
26943
+ const allowsHorizontalScroll = overflowX !== 'visible' && overflowX !== 'clip';
26944
+ return (canScrollVertically && allowsVerticalScroll) || (canScrollHorizontally && allowsHorizontalScroll);
26945
+ }
26946
+ static canElementConsumeWheel(element, deltaX, deltaY) {
26947
+ const canConsumeVertical = this.canElementConsumeVerticalWheel(element, deltaY);
26948
+ const canConsumeHorizontal = this.canElementConsumeHorizontalWheel(element, deltaX);
26949
+ // Keep wheel events inside the custom element whenever at least one
26950
+ // requested axis can still be consumed by its scroll container.
26951
+ return canConsumeVertical || canConsumeHorizontal;
26952
+ }
26953
+ static canElementConsumeVerticalWheel(element, deltaY) {
26954
+ if (deltaY === 0) {
26955
+ return false;
26956
+ }
26957
+ const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
26958
+ if (maxScrollTop === 0) {
26959
+ return false;
26960
+ }
26961
+ const epsilon = 1;
26962
+ const scrollingDown = deltaY > 0;
26963
+ const scrollingUp = deltaY < 0;
26964
+ if (scrollingDown) {
26965
+ return element.scrollTop < maxScrollTop - epsilon;
26966
+ }
26967
+ if (scrollingUp) {
26968
+ return element.scrollTop > epsilon;
26969
+ }
26970
+ return false;
26971
+ }
26972
+ static canElementConsumeHorizontalWheel(element, deltaX) {
26973
+ if (deltaX === 0) {
26974
+ return false;
26975
+ }
26976
+ const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth);
26977
+ if (maxScrollLeft === 0) {
26978
+ return false;
26979
+ }
26980
+ const epsilon = 1;
26981
+ const scrollingRight = deltaX > 0;
26982
+ const scrollingLeft = deltaX < 0;
26983
+ if (scrollingRight) {
26984
+ return element.scrollLeft < maxScrollLeft - epsilon;
26985
+ }
26986
+ if (scrollingLeft) {
26987
+ return element.scrollLeft > epsilon;
26988
+ }
26989
+ return false;
26990
+ }
26991
+ }
26992
+
26993
+ /**
26994
+ * Helper class for exporting Kritzel objects to SVG format.
26995
+ * Generates clean, standards-compliant SVG markup from canvas objects.
26996
+ */
26997
+ class KritzelSvgExportHelper {
26998
+ /**
26999
+ * Calculates the combined bounding box that encompasses all objects.
27000
+ * Uses rotatedBoundingBox to account for object rotation.
27001
+ * @param objects - Array of objects to calculate bounds for
27002
+ * @returns Combined bounding box, or null if no objects provided
27003
+ */
27004
+ static calculateCombinedBounds(objects) {
27005
+ if (!objects || objects.length === 0) {
27006
+ return null;
27007
+ }
27008
+ let minX = Infinity;
27009
+ let minY = Infinity;
27010
+ let maxX = -Infinity;
27011
+ let maxY = -Infinity;
27012
+ for (const obj of objects) {
27013
+ const bbox = obj.rotatedBoundingBox;
27014
+ minX = Math.min(minX, bbox.x);
27015
+ minY = Math.min(minY, bbox.y);
27016
+ maxX = Math.max(maxX, bbox.x + bbox.width);
27017
+ maxY = Math.max(maxY, bbox.y + bbox.height);
27018
+ }
27019
+ return {
27020
+ x: minX,
27021
+ y: minY,
27022
+ z: 1,
27023
+ width: maxX - minX,
27024
+ height: maxY - minY,
27025
+ };
27026
+ }
27027
+ /**
27028
+ * Generates a complete SVG document string from the given objects.
27029
+ * @param objects - Objects to export
27030
+ * @param options - Export options
27031
+ * @returns SVG document string
27032
+ */
27033
+ static generateSvg(objects, options = {}) {
27034
+ const { theme, padding = 0, includeXmlDeclaration = true, rasterSafeForCanvas = false, imageDataUrls } = options;
27035
+ if (!objects || objects.length === 0) {
27036
+ return '';
27037
+ }
27038
+ // Flatten groups to get all objects including nested children
27039
+ const allObjects = this.flattenObjects(objects);
27040
+ const bounds = this.calculateCombinedBounds(allObjects);
27041
+ if (!bounds) {
27042
+ return '';
27043
+ }
27044
+ // Add padding to bounds
27045
+ const viewBoxX = bounds.x - padding;
27046
+ const viewBoxY = bounds.y - padding;
27047
+ const viewBoxWidth = bounds.width + padding * 2;
27048
+ const viewBoxHeight = bounds.height + padding * 2;
27049
+ // Collect all defs (markers, patterns, etc.)
27050
+ const defs = this.collectDefs(allObjects, theme);
27051
+ // Generate SVG elements for each object
27052
+ const elements = objects
27053
+ .sort((a, b) => a.zIndex - b.zIndex)
27054
+ .map(obj => this.objectToSvgElement(obj, theme, imageDataUrls, rasterSafeForCanvas))
27055
+ .filter(Boolean)
27056
+ .join('\n ');
27057
+ const xmlDecl = includeXmlDeclaration ? '<?xml version="1.0" encoding="UTF-8"?>\n' : '';
27058
+ const defsContent = defs.length > 0 ? `\n <defs>\n ${defs.join('\n ')}\n </defs>` : '';
27059
+ return `${xmlDecl}<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBoxX} ${viewBoxY} ${viewBoxWidth} ${viewBoxHeight}" width="${viewBoxWidth}" height="${viewBoxHeight}">${defsContent}
27060
+ ${elements}
27061
+ </svg>`;
27062
+ }
27063
+ /**
27064
+ * Flattens a list of objects, expanding groups into their children recursively.
27065
+ * @param objects - Objects to flatten
27066
+ * @returns Flat array of all objects including group children
27067
+ */
27068
+ static flattenObjects(objects) {
27069
+ const result = [];
27070
+ for (const obj of objects) {
27071
+ if (schema_constants.KritzelClassHelper.isInstanceOf(obj, 'KritzelGroup')) {
27072
+ result.push(...this.flattenObjects(obj.children));
27073
+ }
27074
+ else {
27075
+ result.push(obj);
27076
+ }
27077
+ }
27078
+ return result;
27079
+ }
27080
+ /**
27081
+ * Resolves every image referenced by the given objects (including those
27082
+ * nested inside groups) to an inlined `data:` URL, keyed by object id.
27083
+ *
27084
+ * This is required for export flows that rasterize the SVG (e.g. PNG
27085
+ * export): an SVG loaded into an `<img>` element will NOT load external
27086
+ * or `blob:` resources, so a `<image href="blob:...">` renders as a
27087
+ * broken-image placeholder. Embedding the bytes as a base64 `data:` URL
27088
+ * makes the image self-contained and survive rasterization.
27089
+ *
27090
+ * Resolution is best-effort: images that fail to resolve are simply
27091
+ * omitted from the map, and the SVG falls back to whatever `href` the
27092
+ * synchronous path would have produced.
27093
+ * @param objects - Objects to inline images for
27094
+ * @param assetResolver - Resolver used to fetch asset bytes; optional
27095
+ * @returns Map of image object id -> inlined `data:` URL
27096
+ */
27097
+ static async resolveImageDataUrls(objects, assetResolver) {
27098
+ const dataUrls = new Map();
27099
+ if (!objects || objects.length === 0) {
27100
+ return dataUrls;
27101
+ }
27102
+ const images = this.flattenObjects(objects).filter((obj) => schema_constants.KritzelClassHelper.isInstanceOf(obj, 'KritzelImage'));
27103
+ await Promise.all(images.map(async (image) => {
27104
+ // Legacy inline data URL is already embeddable as-is.
27105
+ if (image.src?.startsWith('data:')) {
27106
+ dataUrls.set(image.id, image.src);
27107
+ return;
27108
+ }
27109
+ // A resolvedSrc that is itself a data URL can be embedded directly.
27110
+ if (image.resolvedSrc?.startsWith('data:')) {
27111
+ dataUrls.set(image.id, image.resolvedSrc);
27112
+ return;
27113
+ }
27114
+ if (!image.assetId || !assetResolver) {
27115
+ return;
27116
+ }
27117
+ try {
27118
+ const blob = await assetResolver.fetchBlob(image.assetId);
27119
+ dataUrls.set(image.id, await this.blobToDataUrl(blob));
27120
+ }
27121
+ catch (err) {
27122
+ console.warn(`[KritzelSvgExportHelper] Failed to inline image asset ${image.assetId} for export:`, err);
27123
+ }
27124
+ }));
27125
+ return dataUrls;
27126
+ }
27127
+ /**
27128
+ * Reads a Blob into a base64 `data:` URL.
27129
+ * @param blob - The blob to encode
27130
+ * @returns Promise resolving to the data URL string
27131
+ */
27132
+ static async blobToDataUrl(blob) {
27133
+ // Prefer FileReader in browser-like runtimes for efficiency.
27134
+ const hasFileReader = typeof FileReader !== 'undefined';
27135
+ if (hasFileReader) {
27136
+ try {
27137
+ return await new Promise((resolve, reject) => {
27138
+ const reader = new FileReader();
27139
+ reader.onload = () => {
27140
+ if (typeof reader.result === 'string') {
27141
+ resolve(reader.result);
27142
+ return;
27143
+ }
27144
+ reject(new Error('FileReader result is not a string'));
27145
+ };
27146
+ reader.onerror = () => reject(reader.error ?? new Error('Failed to read blob as data URL'));
27147
+ reader.readAsDataURL(blob);
27148
+ });
27149
+ }
27150
+ catch {
27151
+ // Fall through to the ArrayBuffer-based encoder.
27152
+ }
27153
+ }
27154
+ const bytes = new Uint8Array(await blob.arrayBuffer());
27155
+ const mimeType = blob.type || 'application/octet-stream';
27156
+ return `data:${mimeType};base64,${this.uint8ToBase64(bytes)}`;
27157
+ }
27158
+ /**
27159
+ * Encodes a byte array to base64 across browser and Node-like runtimes.
27160
+ */
27161
+ static uint8ToBase64(bytes) {
27162
+ if (typeof Buffer !== 'undefined') {
27163
+ return Buffer.from(bytes).toString('base64');
27164
+ }
27165
+ let binary = '';
27166
+ const chunkSize = 0x8000;
27167
+ for (let i = 0; i < bytes.length; i += chunkSize) {
27168
+ const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length));
27169
+ binary += String.fromCharCode(...chunk);
27170
+ }
27171
+ if (typeof btoa === 'function') {
27172
+ return btoa(binary);
27173
+ }
27174
+ throw new Error('No base64 encoder available in this runtime');
27175
+ }
27176
+ /**
27177
+ * Collects SVG defs (markers, patterns, etc.) needed by the objects.
27178
+ * @param objects - Objects to collect defs for
27179
+ * @param theme - Theme for color resolution
27180
+ * @returns Array of def element strings
27181
+ */
27182
+ static collectDefs(objects, theme) {
27183
+ const defs = [];
27184
+ for (const obj of objects) {
27185
+ if (schema_constants.KritzelClassHelper.isInstanceOf(obj, 'KritzelLine')) {
27186
+ if (obj.hasStartArrow) {
27187
+ defs.push(this.generateArrowMarker(obj, 'start', theme));
27188
+ }
27189
+ if (obj.hasEndArrow) {
27190
+ defs.push(this.generateArrowMarker(obj, 'end', theme));
27191
+ }
27192
+ }
27193
+ }
27194
+ return defs;
27195
+ }
27196
+ /**
27197
+ * Generates an SVG marker element for line arrows.
27198
+ * @param line - The line object
27199
+ * @param end - Which end of the line ('start' or 'end')
27200
+ * @param theme - Theme for color resolution
27201
+ * @returns SVG marker element string
27202
+ */
27203
+ static generateArrowMarker(line, end, _theme) {
27204
+ const markerId = end === 'start' ? line.startMarkerId : line.endMarkerId;
27205
+ const size = line.getArrowSize(end);
27206
+ const fill = line.getArrowFill(end);
27207
+ const style = end === 'start' ? line.arrows?.start?.style : line.arrows?.end?.style;
27208
+ const path = line.getArrowPath(style);
27209
+ const orient = end === 'start' ? 'auto-start-reverse' : 'auto';
27210
+ const refX = 0;
27211
+ const refY = size / 2;
27212
+ return `<marker id="${markerId}" markerWidth="${size}" markerHeight="${size}" refX="${refX}" refY="${refY}" orient="${orient}" markerUnits="userSpaceOnUse">
27213
+ <path d="${path}" fill="${fill}" transform="scale(${size / 10})"/>
27214
+ </marker>`;
27215
+ }
27216
+ /**
27217
+ * Converts a Kritzel object to its SVG element representation.
27218
+ * @param object - The object to convert
27219
+ * @param theme - Theme for color resolution
27220
+ * @param imageDataUrls - Optional map of image id -> inlined data URL
27221
+ * @returns SVG element string, or empty string if object type is not supported
27222
+ */
27223
+ static objectToSvgElement(object, theme, imageDataUrls, rasterSafeForCanvas = false) {
27224
+ if (schema_constants.KritzelClassHelper.isInstanceOf(object, 'KritzelPath')) {
27225
+ return this.pathToSvg(object, theme);
27226
+ }
27227
+ if (schema_constants.KritzelClassHelper.isInstanceOf(object, 'KritzelLine')) {
27228
+ return this.lineToSvg(object, theme);
27229
+ }
27230
+ if (schema_constants.KritzelClassHelper.isInstanceOf(object, 'KritzelShape')) {
27231
+ return this.shapeToSvg(object, theme);
27232
+ }
27233
+ if (schema_constants.KritzelClassHelper.isInstanceOf(object, 'KritzelText')) {
27234
+ return this.textToSvg(object, theme, rasterSafeForCanvas);
27235
+ }
27236
+ if (schema_constants.KritzelClassHelper.isInstanceOf(object, 'KritzelImage')) {
27237
+ return this.imageToSvg(object, imageDataUrls);
27238
+ }
27239
+ if (schema_constants.KritzelClassHelper.isInstanceOf(object, 'KritzelCustomElement')) {
27240
+ return this.customElementToSvg(object, rasterSafeForCanvas);
27241
+ }
27242
+ if (schema_constants.KritzelClassHelper.isInstanceOf(object, 'KritzelGroup')) {
27243
+ return this.groupToSvg(object, theme, imageDataUrls, rasterSafeForCanvas);
27244
+ }
27245
+ return '';
27246
+ }
27247
+ /**
27248
+ * Converts a KritzelPath to SVG.
27249
+ * @param path - The path object
27250
+ * @param theme - Theme for color resolution
27251
+ * @returns SVG element string
27252
+ */
27253
+ static pathToSvg(path, theme) {
27254
+ const transform = this.buildTransform(path);
27255
+ const fill = schema_constants.KritzelColorHelper.resolveThemeColor(path.fill, theme);
27256
+ const stroke = schema_constants.KritzelColorHelper.resolveThemeColor(path.stroke, theme);
27257
+ const opacity = path.opacity !== 1 ? ` opacity="${path.opacity}"` : '';
27258
+ const scale = this.getObjectScale(path);
27259
+ // Path needs to be wrapped in a g with translation since path.d uses local coordinates
27260
+ return `<g transform="${transform}"${opacity}>
27261
+ <svg viewBox="${path.viewBox}" width="${path.totalWidth / scale}" height="${path.totalHeight / scale}" overflow="visible">
27262
+ <path d="${path.d}" fill="${fill || 'none'}" stroke="${stroke || 'none'}"${path.strokeWidth ? ` stroke-width="${path.strokeWidth}"` : ''}/>
27263
+ </svg>
27264
+ </g>`;
27265
+ }
27266
+ /**
27267
+ * Converts a KritzelLine to SVG.
27268
+ * @param line - The line object
27269
+ * @param theme - Theme for color resolution
27270
+ * @returns SVG element string
27271
+ */
27272
+ static lineToSvg(line, theme) {
27273
+ const transform = this.buildTransform(line);
27274
+ const stroke = schema_constants.KritzelColorHelper.resolveThemeColor(line.stroke, theme);
27275
+ const opacity = line.opacity !== 1 ? ` opacity="${line.opacity}"` : '';
27276
+ const scale = this.getObjectScale(line);
27277
+ const markerStart = line.hasStartArrow ? ` marker-start="url(#${line.startMarkerId})"` : '';
27278
+ const markerEnd = line.hasEndArrow ? ` marker-end="url(#${line.endMarkerId})"` : '';
27279
+ return `<g transform="${transform}"${opacity}>
27280
+ <svg viewBox="${line.viewBox}" width="${line.totalWidth / scale}" height="${line.totalHeight / scale}" overflow="visible">
27281
+ <path d="${line.d}" fill="none" stroke="${stroke}" stroke-width="${line.strokeWidth}" stroke-linecap="round"${markerStart}${markerEnd}/>
27282
+ </svg>
27283
+ </g>`;
27284
+ }
27285
+ /**
27286
+ * Converts a KritzelShape to SVG.
27287
+ * @param shape - The shape object
27288
+ * @param theme - Theme for color resolution
27289
+ * @returns SVG element string
27290
+ */
27291
+ static shapeToSvg(shape, theme) {
27292
+ const transform = this.buildTransform(shape);
27293
+ const fill = schema_constants.KritzelColorHelper.resolveThemeColor(shape.fillColor, theme);
27294
+ const stroke = schema_constants.KritzelColorHelper.resolveThemeColor(shape.strokeColor, theme);
27295
+ const opacity = shape.opacity !== 1 ? ` opacity="${shape.opacity}"` : '';
27296
+ const scale = this.getObjectScale(shape);
27297
+ // Get the SVG path for the shape
27298
+ const pathD = shape.getSvgPath();
27299
+ // Handle text content if present
27300
+ let textContent = '';
27301
+ if (shape.editor && !shape.editor.state.doc.textContent.trim()) ;
27302
+ else if (shape.content) {
27303
+ const textHtml = this.prosemirrorToHtml(shape.content, shape, theme);
27304
+ if (textHtml) {
27305
+ const fontColor = schema_constants.KritzelColorHelper.resolveThemeColor(shape.fontColor, theme);
27306
+ textContent = `
27307
+ <foreignObject x="${shape.x}" y="${shape.y}" width="${shape.width}" height="${shape.height}">
27308
+ <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;">
27309
+ ${textHtml}
27310
+ </div>
27311
+ </foreignObject>`;
27312
+ }
27313
+ }
27314
+ return `<g transform="${transform}"${opacity}>
27315
+ <svg viewBox="${shape.viewBox}" width="${shape.totalWidth / scale}" height="${shape.totalHeight / scale}" overflow="visible" preserveAspectRatio="none">
27316
+ <path d="${pathD}" fill="${fill || 'transparent'}" stroke="${stroke}" stroke-width="${shape.strokeWidth}"/>${textContent}
27317
+ </svg>
27318
+ </g>`;
27319
+ }
27320
+ /**
27321
+ * Converts a KritzelText to SVG using foreignObject for rich text support.
27322
+ * @param text - The text object
27323
+ * @param theme - Theme for color resolution
27324
+ * @returns SVG element string
27325
+ */
27326
+ static textToSvg(text, theme, rasterSafeForCanvas = false) {
27327
+ if (rasterSafeForCanvas) {
27328
+ return this.textToSvgRasterSafe(text, theme);
27329
+ }
27330
+ const transform = this.buildTransform(text);
27331
+ const opacity = text.opacity !== 1 ? ` opacity="${text.opacity}"` : '';
27332
+ const fontColor = schema_constants.KritzelColorHelper.resolveThemeColor(text.fontColor, theme);
27333
+ const bgColor = schema_constants.KritzelColorHelper.resolveThemeColor(text.backgroundColor, theme);
27334
+ const scale = this.getObjectScale(text);
27335
+ // Convert ProseMirror content to HTML
27336
+ const htmlContent = this.prosemirrorToHtml(text.content, text, theme);
27337
+ // Calculate dimensions in SVG world units (world units = CSS px in a 1:1 SVG).
27338
+ const width = text.totalWidth / scale;
27339
+ const height = text.totalHeight / scale;
27340
+ // Convert the font size from pt to SVG world units (px equivalent).
27341
+ // text.fontSize is stored in CSS pt; 1 pt = 4/3 px at 96 dpi.
27342
+ // We also bake in scaleFactor (user resize) and divide by the object scale so
27343
+ // that the rendered font aligns with the world-unit dimensions above.
27344
+ // The CSS transform: scale(scaleFactor) is removed from the div because the
27345
+ // adjusted font-size already accounts for it – keeping both would double-scale
27346
+ // the content and overflow the foreignObject.
27347
+ const svgFontSizePx = (text.fontSize * (4 / 3) * (text.scaleFactor || 1) / scale).toFixed(4);
27348
+ return `<g transform="${transform}"${opacity}>
27349
+ <foreignObject x="0" y="0" width="${width}" height="${height}" overflow="visible">
27350
+ <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>
27351
+ </foreignObject>
27352
+ </g>`;
27353
+ }
27354
+ /**
27355
+ * Converts a KritzelText to SVG using pure SVG text primitives.
27356
+ * This path avoids `<foreignObject>`, which taints canvases when the
27357
+ * generated SVG is rasterized through `<img>` and `drawImage`.
27358
+ */
27359
+ static textToSvgRasterSafe(text, theme) {
27360
+ const transform = this.buildTransform(text);
27361
+ const opacity = text.opacity !== 1 ? ` opacity="${text.opacity}"` : '';
27362
+ const fontColor = schema_constants.KritzelColorHelper.resolveThemeColor(text.fontColor, theme);
27363
+ const bgColor = schema_constants.KritzelColorHelper.resolveThemeColor(text.backgroundColor, theme);
27364
+ const scale = this.getObjectScale(text);
27365
+ const width = text.totalWidth / scale;
27366
+ const height = text.totalHeight / scale;
27367
+ const lines = this.prosemirrorToPlainTextLines(text.content);
27368
+ const resolvedLines = lines.length > 0 ? lines : [''];
27369
+ // Convert the font size from pt to SVG world units (px equivalent).
27370
+ // text.fontSize is stored in CSS pt; 1 pt = 4/3 px at 96 dpi.
27371
+ // We also bake in scaleFactor (user resize) and divide by the object scale so
27372
+ // that the rendered lines align with the world-unit bounding box dimensions above.
27373
+ const resolvedFontSizePx = Math.max(1, text.fontSize * (4 / 3) * (text.scaleFactor || 1) / scale);
27374
+ const lineHeight = resolvedFontSizePx * 1.2;
27375
+ const startY = resolvedFontSizePx;
27376
+ const bgRect = bgColor && bgColor !== 'transparent'
27377
+ ? `<rect x="0" y="0" width="${width}" height="${height}" fill="${bgColor}"/>`
27378
+ : '';
27379
+ const tspans = resolvedLines
27380
+ .map((line, index) => {
27381
+ const y = startY + index * lineHeight;
27382
+ return `<tspan x="0" y="${y}" xml:space="preserve">${this.escapeHtml(line)}</tspan>`;
27383
+ })
27384
+ .join('');
27385
+ return `<g transform="${transform}"${opacity}>
27386
+ ${bgRect}
27387
+ <text font-family="${this.escapeHtml(text.fontFamily)}" font-size="${resolvedFontSizePx}" fill="${fontColor}">${tspans}</text>
27388
+ </g>`;
27446
27389
  }
27447
- canvas.style.width = `${canvasWidth}`;
27448
- canvas.style.height = `${canvasHeight}`;
27449
- if (options.backgroundColor) {
27450
- context.fillStyle = options.backgroundColor;
27451
- context.fillRect(0, 0, canvas.width, canvas.height);
27390
+ /**
27391
+ * Converts a KritzelImage to SVG.
27392
+ *
27393
+ * Prefers an inlined `data:` URL from `imageDataUrls` (keyed by object
27394
+ * id) when provided, so the embedded bytes survive rasterization to
27395
+ * PNG. Falls back to `resolvedSrc` (populated by the renderer via the
27396
+ * asset resolver) and finally the legacy inline `src` field for
27397
+ * documents persisted before the asset layer existed.
27398
+ *
27399
+ * Note: a `blob:` `resolvedSrc` renders correctly on-canvas but becomes
27400
+ * a broken-image placeholder once the SVG is loaded into an `<img>` and
27401
+ * drawn to a canvas. Callers that rasterize the SVG must pass
27402
+ * `imageDataUrls` (see {@link KritzelSvgExportHelper.resolveImageDataUrls}).
27403
+ * @param image - The image object
27404
+ * @param imageDataUrls - Optional map of image id -> inlined data URL
27405
+ * @returns SVG element string
27406
+ */
27407
+ static imageToSvg(image, imageDataUrls) {
27408
+ const transform = this.buildTransform(image);
27409
+ const opacity = image.opacity !== 1 ? ` opacity="${image.opacity}"` : '';
27410
+ const scale = this.getObjectScale(image);
27411
+ // Calculate dimensions
27412
+ const width = image.totalWidth / scale;
27413
+ const height = image.totalHeight / scale;
27414
+ const href = imageDataUrls?.get(image.id) || image.resolvedSrc || image.src || '';
27415
+ return `<g transform="${transform}"${opacity}>
27416
+ <image href="${href}" x="0" y="0" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet"/>
27417
+ </g>`;
27452
27418
  }
27453
- context.drawImage(img, 0, 0, canvas.width, canvas.height);
27454
- return canvas;
27455
- }
27456
- async function toPng(node, options = {}) {
27457
- const canvas = await toCanvas(node, options);
27458
- return canvas.toDataURL();
27459
- }
27460
-
27461
- class KritzelWheelHelper {
27462
- static shouldKeepWheelInCustomElement(ev, customElement) {
27463
- // Keep Ctrl+wheel available for viewport zoom gestures.
27464
- if (ev.ctrlKey || customElement.isSelected !== true) {
27465
- return false;
27466
- }
27467
- const path = typeof ev.composedPath === 'function' ? ev.composedPath() : [];
27468
- const scrollContainer = this.getScrollableContainerFromEventPath(path, customElement);
27469
- // No scroll container found inside the custom element: let the wheel
27470
- // event pass through so the viewport can continue handling it.
27471
- if (!scrollContainer) {
27472
- return false;
27419
+ /**
27420
+ * Converts a KritzelCustomElement to SVG.
27421
+ * SVG mode: embeds HTML in foreignObject (best-effort).
27422
+ * PNG/raster mode: renders placeholder rect with warning.
27423
+ * @param obj - The custom element object
27424
+ * @param rasterSafeForCanvas - If true, render placeholder (for PNG export)
27425
+ * @returns SVG element string
27426
+ */
27427
+ static customElementToSvg(obj, rasterSafeForCanvas = false) {
27428
+ const transform = this.buildTransform(obj);
27429
+ const opacity = obj.opacity !== 1 ? ` opacity="${obj.opacity}"` : '';
27430
+ const scale = this.getObjectScale(obj);
27431
+ const width = obj.totalWidth / scale;
27432
+ const height = obj.totalHeight / scale;
27433
+ if (rasterSafeForCanvas) {
27434
+ // PNG export: render placeholder rect (foreignObject doesn't rasterize to PNG well)
27435
+ console.warn('[KritzelCustomElement SVG Export] Custom elements do not serialize well to PNG. Use SVG export or screenshot instead.');
27436
+ return `<g transform="${transform}"${opacity}>
27437
+ <rect x="0" y="0" width="${width}" height="${height}" fill="#f0f0f0" stroke="#999" stroke-width="1" />
27438
+ <text x="${width / 2}" y="${height / 2}" text-anchor="middle" dominant-baseline="middle" font-size="12" fill="#999">[Custom Element]</text>
27439
+ </g>`;
27473
27440
  }
27474
- return this.canElementConsumeWheel(scrollContainer, ev.deltaX, ev.deltaY);
27441
+ // SVG export: use foreignObject to embed HTML
27442
+ const html = obj.element?.outerHTML || '<div style="border:1px dashed #ccc">Empty</div>';
27443
+ return `<g transform="${transform}"${opacity}>
27444
+ <foreignObject x="0" y="0" width="${width}" height="${height}">
27445
+ <div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;overflow:hidden;">
27446
+ ${html}
27447
+ </div>
27448
+ </foreignObject>
27449
+ </g>`;
27475
27450
  }
27476
- static getScrollableContainerFromEventPath(path, customElement) {
27477
- const potentialRoots = [];
27478
- if (customElement.element instanceof HTMLElement) {
27479
- potentialRoots.push(customElement.element);
27480
- }
27481
- if (customElement.elementRef instanceof HTMLElement) {
27482
- potentialRoots.push(customElement.elementRef);
27483
- }
27484
- const legacyElementRef = customElement.elmeentRef;
27485
- if (legacyElementRef instanceof HTMLElement) {
27486
- potentialRoots.push(legacyElementRef);
27487
- }
27488
- for (const root of potentialRoots) {
27489
- const rootIndex = path.findIndex(entry => entry === root);
27490
- if (rootIndex === -1) {
27491
- continue;
27492
- }
27493
- for (const entry of path.slice(0, rootIndex + 1)) {
27494
- if (!(entry instanceof HTMLElement)) {
27495
- continue;
27496
- }
27497
- if (this.isScrollableElement(entry)) {
27498
- return entry;
27499
- }
27500
- }
27501
- }
27502
- // Fallback for synthetic/custom wheel events where composedPath is absent
27503
- // or does not include the custom-element subtree.
27504
- for (const root of potentialRoots) {
27505
- if (this.isScrollableElement(root)) {
27506
- return root;
27507
- }
27508
- const firstChild = root.firstElementChild;
27509
- if (firstChild instanceof HTMLElement && this.isScrollableElement(firstChild)) {
27510
- return firstChild;
27511
- }
27451
+ /**
27452
+ * Converts a KritzelGroup to SVG by recursively converting children.
27453
+ * @param group - The group object
27454
+ * @param theme - Theme for color resolution
27455
+ * @param imageDataUrls - Optional map of image id -> inlined data URL
27456
+ * @returns SVG element string
27457
+ */
27458
+ static groupToSvg(group, theme, imageDataUrls, rasterSafeForCanvas = false) {
27459
+ const children = group.children
27460
+ .sort((a, b) => a.zIndex - b.zIndex)
27461
+ .map(child => this.objectToSvgElement(child, theme, imageDataUrls, rasterSafeForCanvas))
27462
+ .filter(Boolean)
27463
+ .join('\n ');
27464
+ if (!children) {
27465
+ return '';
27512
27466
  }
27513
- return null;
27467
+ const opacity = group.opacity !== 1 ? ` opacity="${group.opacity}"` : '';
27468
+ return `<g${opacity}>
27469
+ ${children}
27470
+ </g>`;
27514
27471
  }
27515
- static isScrollableElement(element) {
27516
- const canScrollVertically = element.scrollHeight - element.clientHeight > 0;
27517
- const canScrollHorizontally = element.scrollWidth - element.clientWidth > 0;
27518
- if (!canScrollVertically && !canScrollHorizontally) {
27519
- return false;
27472
+ /**
27473
+ * Builds a transform attribute string for an object.
27474
+ * Combines translation and rotation.
27475
+ * @param object - The object to build transform for
27476
+ * @returns Transform attribute value string
27477
+ */
27478
+ static buildTransform(object) {
27479
+ const transforms = [];
27480
+ const scale = this.getObjectScale(object);
27481
+ // Translation
27482
+ transforms.push(`translate(${object.translateX}, ${object.translateY})`);
27483
+ // Rotation around center
27484
+ if (object.rotation !== 0) {
27485
+ const centerX = object.totalWidth / 2 / scale;
27486
+ const centerY = object.totalHeight / 2 / scale;
27487
+ const degrees = object.rotation * (180 / Math.PI);
27488
+ transforms.push(`rotate(${degrees}, ${centerX}, ${centerY})`);
27520
27489
  }
27521
- const computedStyle = window.getComputedStyle(element);
27522
- const overflowX = computedStyle.overflowX;
27523
- const overflowY = computedStyle.overflowY;
27524
- const allowsVerticalScroll = overflowY !== 'visible' && overflowY !== 'clip';
27525
- const allowsHorizontalScroll = overflowX !== 'visible' && overflowX !== 'clip';
27526
- return (canScrollVertically && allowsVerticalScroll) || (canScrollHorizontally && allowsHorizontalScroll);
27490
+ return transforms.join(' ');
27527
27491
  }
27528
- static canElementConsumeWheel(element, deltaX, deltaY) {
27529
- const canConsumeVertical = this.canElementConsumeVerticalWheel(element, deltaY);
27530
- const canConsumeHorizontal = this.canElementConsumeHorizontalWheel(element, deltaX);
27531
- // Keep wheel events inside the custom element whenever at least one
27532
- // requested axis can still be consumed by its scroll container.
27533
- return canConsumeVertical || canConsumeHorizontal;
27492
+ /**
27493
+ * Returns a safe non-zero scale for export geometry calculations.
27494
+ */
27495
+ static getObjectScale(object) {
27496
+ const scale = object.scale ?? 1;
27497
+ return scale === 0 ? 1 : scale;
27534
27498
  }
27535
- static canElementConsumeVerticalWheel(element, deltaY) {
27536
- if (deltaY === 0) {
27537
- return false;
27499
+ /**
27500
+ * Converts ProseMirror JSON content to HTML string.
27501
+ * @param content - ProseMirror document JSON
27502
+ * @param context - The text or shape object for styling context
27503
+ * @param theme - Theme for color resolution
27504
+ * @returns HTML string
27505
+ */
27506
+ static prosemirrorToHtml(content, _context, theme) {
27507
+ if (!content || !content.content) {
27508
+ return '';
27538
27509
  }
27539
- const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
27540
- if (maxScrollTop === 0) {
27541
- return false;
27510
+ return content.content
27511
+ .map((node) => this.nodeToHtml(node, theme))
27512
+ .join('');
27513
+ }
27514
+ /**
27515
+ * Converts a ProseMirror node to HTML.
27516
+ * @param node - ProseMirror node JSON
27517
+ * @param theme - Theme for color resolution
27518
+ * @returns HTML string
27519
+ */
27520
+ static nodeToHtml(node, theme) {
27521
+ if (!node)
27522
+ return '';
27523
+ switch (node.type) {
27524
+ case 'paragraph':
27525
+ const pContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
27526
+ return `<p style="margin: 0;">${pContent || '<br/>'}</p>`;
27527
+ case 'text':
27528
+ let text = this.escapeHtml(node.text || '');
27529
+ if (node.marks) {
27530
+ for (const mark of node.marks) {
27531
+ text = this.applyMark(text, mark, theme);
27532
+ }
27533
+ }
27534
+ return text;
27535
+ case 'hard_break':
27536
+ return '<br/>';
27537
+ case 'bullet_list':
27538
+ const blContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
27539
+ return `<ul style="margin: 0; padding-left: 1.5em;">${blContent}</ul>`;
27540
+ case 'ordered_list':
27541
+ const olContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
27542
+ return `<ol style="margin: 0; padding-left: 1.5em;">${olContent}</ol>`;
27543
+ case 'list_item':
27544
+ const liContent = node.content ? node.content.map((n) => this.nodeToHtml(n, theme)).join('') : '';
27545
+ return `<li>${liContent}</li>`;
27546
+ default:
27547
+ // For unknown node types, try to render content
27548
+ if (node.content) {
27549
+ return node.content.map((n) => this.nodeToHtml(n, theme)).join('');
27550
+ }
27551
+ return '';
27542
27552
  }
27543
- const epsilon = 1;
27544
- const scrollingDown = deltaY > 0;
27545
- const scrollingUp = deltaY < 0;
27546
- if (scrollingDown) {
27547
- return element.scrollTop < maxScrollTop - epsilon;
27553
+ }
27554
+ /**
27555
+ * Converts ProseMirror JSON content to plain-text lines.
27556
+ * Used by raster-safe SVG export, which cannot depend on HTML/foreignObject.
27557
+ */
27558
+ static prosemirrorToPlainTextLines(content) {
27559
+ if (!content || !Array.isArray(content.content)) {
27560
+ return [];
27548
27561
  }
27549
- if (scrollingUp) {
27550
- return element.scrollTop > epsilon;
27562
+ const plainText = content.content
27563
+ .map((node) => this.nodeToPlainText(node))
27564
+ .join('')
27565
+ .replace(/\r\n/g, '\n')
27566
+ .replace(/\n+$/, '');
27567
+ if (!plainText) {
27568
+ return [];
27551
27569
  }
27552
- return false;
27570
+ return plainText.split('\n');
27553
27571
  }
27554
- static canElementConsumeHorizontalWheel(element, deltaX) {
27555
- if (deltaX === 0) {
27556
- return false;
27557
- }
27558
- const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth);
27559
- if (maxScrollLeft === 0) {
27560
- return false;
27572
+ /**
27573
+ * Converts a ProseMirror node tree into plain text.
27574
+ */
27575
+ static nodeToPlainText(node) {
27576
+ if (!node) {
27577
+ return '';
27561
27578
  }
27562
- const epsilon = 1;
27563
- const scrollingRight = deltaX > 0;
27564
- const scrollingLeft = deltaX < 0;
27565
- if (scrollingRight) {
27566
- return element.scrollLeft < maxScrollLeft - epsilon;
27579
+ switch (node.type) {
27580
+ case 'text':
27581
+ return node.text || '';
27582
+ case 'hard_break':
27583
+ return '\n';
27584
+ case 'paragraph': {
27585
+ const paragraph = Array.isArray(node.content)
27586
+ ? node.content.map((child) => this.nodeToPlainText(child)).join('')
27587
+ : '';
27588
+ return `${paragraph}\n`;
27589
+ }
27590
+ case 'list_item': {
27591
+ const item = Array.isArray(node.content)
27592
+ ? node.content.map((child) => this.nodeToPlainText(child)).join('')
27593
+ : '';
27594
+ return `- ${item.trim()}\n`;
27595
+ }
27596
+ default:
27597
+ return Array.isArray(node.content)
27598
+ ? node.content.map((child) => this.nodeToPlainText(child)).join('')
27599
+ : '';
27567
27600
  }
27568
- if (scrollingLeft) {
27569
- return element.scrollLeft > epsilon;
27601
+ }
27602
+ /**
27603
+ * Applies a ProseMirror mark to text.
27604
+ * @param text - The text to wrap
27605
+ * @param mark - The mark to apply
27606
+ * @param theme - Theme for color resolution
27607
+ * @returns Wrapped HTML string
27608
+ */
27609
+ static applyMark(text, mark, _theme) {
27610
+ switch (mark.type) {
27611
+ case 'bold':
27612
+ case 'strong':
27613
+ return `<strong>${text}</strong>`;
27614
+ case 'italic':
27615
+ case 'em':
27616
+ return `<em>${text}</em>`;
27617
+ case 'underline':
27618
+ return `<u>${text}</u>`;
27619
+ case 'strike':
27620
+ return `<s>${text}</s>`;
27621
+ case 'code':
27622
+ return `<code>${text}</code>`;
27623
+ case 'link':
27624
+ const href = mark.attrs?.href || '#';
27625
+ return `<a href="${this.escapeHtml(href)}">${text}</a>`;
27626
+ default:
27627
+ return text;
27570
27628
  }
27571
- return false;
27629
+ }
27630
+ /**
27631
+ * Escapes HTML special characters.
27632
+ * @param str - String to escape
27633
+ * @returns Escaped string
27634
+ */
27635
+ static escapeHtml(str) {
27636
+ return str
27637
+ .replace(/&/g, '&amp;')
27638
+ .replace(/</g, '&lt;')
27639
+ .replace(/>/g, '&gt;')
27640
+ .replace(/"/g, '&quot;')
27641
+ .replace(/'/g, '&#039;');
27572
27642
  }
27573
27643
  }
27574
27644
 
@@ -28765,10 +28835,7 @@ const KritzelEngine = class {
28765
28835
  console.error('Failed to export viewport as PNG: screenshot could not be generated');
28766
28836
  return;
28767
28837
  }
28768
- const link = document.createElement('a');
28769
- link.download = filename;
28770
- link.href = dataUrl;
28771
- link.click();
28838
+ this.triggerDataUrlDownload(dataUrl, filename);
28772
28839
  }
28773
28840
  catch (error) {
28774
28841
  console.error('Failed to export viewport as PNG:', error);
@@ -28790,129 +28857,57 @@ const KritzelEngine = class {
28790
28857
  console.error('Failed to export viewport as SVG: screenshot could not be generated');
28791
28858
  return;
28792
28859
  }
28793
- const link = document.createElement('a');
28794
- link.download = filename;
28795
- link.href = dataUrl;
28796
- link.click();
28860
+ this.triggerDataUrlDownload(dataUrl, filename);
28797
28861
  }
28798
28862
  catch (error) {
28799
28863
  console.error('Failed to export viewport as SVG:', error);
28800
28864
  }
28801
28865
  }
28802
28866
  /**
28803
- * Generates an SVG string from the currently selected objects.
28804
- * Creates clean, standards-compliant SVG markup by directly serializing object properties.
28805
- * @param options - Optional export settings (theme, padding, raster-safe mode for canvas export).
28806
- * @returns SVG markup string, or null if no objects are selected.
28867
+ * Returns export formats supported by the currently selected object.
28868
+ * Only returns formats when exactly one object is selected.
28807
28869
  */
28808
- async getSelectedObjectsAsSvgString(options) {
28809
- const selectedObjects = await this.getSelectedObjects();
28810
- if (!selectedObjects || selectedObjects.length === 0) {
28811
- return null;
28870
+ async getSelectedObjectSupportedExportFormats() {
28871
+ const selected = this.getSingleSelectedExportableObject();
28872
+ if (!selected) {
28873
+ return [];
28812
28874
  }
28813
- const theme = options?.theme ?? this.core.themeManager.getStoredTheme();
28814
- // Inline image bytes as data URLs so they survive both standalone SVG
28815
- // files and rasterization to PNG (a `blob:` href is not loaded when an
28816
- // SVG is drawn into a canvas, which otherwise yields a broken image).
28817
- const imageDataUrls = await KritzelSvgExportHelper.resolveImageDataUrls(selectedObjects, this.core.assetResolver);
28818
- return KritzelSvgExportHelper.generateSvg(selectedObjects, {
28819
- theme,
28820
- padding: options?.padding ?? 0,
28821
- includeXmlDeclaration: true,
28822
- rasterSafeForCanvas: options?.rasterSafeForCanvas ?? false,
28823
- imageDataUrls,
28824
- });
28875
+ return selected.getSupportedExportFormats();
28825
28876
  }
28826
28877
  /**
28827
- * Exports the currently selected objects as an SVG file and triggers a browser download.
28828
- * Creates clean, standards-compliant SVG markup suitable for import into vector editors.
28829
- * @param options - Optional export settings (theme, padding).
28878
+ * Checks whether the currently selected object supports a specific export format.
28879
+ * Returns false when none or multiple objects are selected.
28830
28880
  */
28831
- async exportSelectedObjectsAsSvg(options) {
28832
- try {
28833
- const svgString = await this.getSelectedObjectsAsSvgString(options);
28834
- if (!svgString) {
28835
- console.warn('No objects selected for SVG export');
28836
- return;
28837
- }
28838
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
28839
- const filename = `selection-${timestamp}.svg`;
28840
- const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
28841
- const url = URL.createObjectURL(blob);
28842
- const link = document.createElement('a');
28843
- link.download = filename;
28844
- link.href = url;
28845
- link.click();
28846
- URL.revokeObjectURL(url);
28847
- }
28848
- catch (error) {
28849
- console.error('Failed to export selected objects as SVG:', error);
28881
+ async canExportSelectedObjectAs(format) {
28882
+ const selected = this.getSingleSelectedExportableObject();
28883
+ if (!selected) {
28884
+ return false;
28850
28885
  }
28886
+ return selected.getSupportedExportFormats().includes(format);
28851
28887
  }
28852
28888
  /**
28853
- * Generates a PNG data URL from the currently selected objects.
28854
- * Converts the SVG representation to a rasterized PNG image.
28855
- * @param options - Optional export settings (theme, padding, scale for resolution).
28856
- * @returns PNG data URL string, or null if no objects are selected.
28889
+ * Exports the currently selected object in the requested format and triggers a file download.
28890
+ * Only supports single selection.
28857
28891
  */
28858
- async getSelectedObjectsAsPngDataUrl(options) {
28859
- const svgString = await this.getSelectedObjectsAsSvgString({
28860
- theme: options?.theme,
28861
- padding: options?.padding,
28862
- // Avoid foreignObject-based text serialization, which taints the
28863
- // canvas in SVG->PNG conversion paths.
28864
- rasterSafeForCanvas: true,
28865
- });
28866
- if (!svgString) {
28867
- return null;
28892
+ async exportSelectedObject(format) {
28893
+ const selected = this.getSingleSelectedExportableObject();
28894
+ if (!selected) {
28895
+ return;
28868
28896
  }
28869
- const scale = options?.scale ?? 2; // Default 2x for better quality
28870
- return new Promise((resolve, reject) => {
28871
- const img = new Image();
28872
- const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
28873
- const url = URL.createObjectURL(svgBlob);
28874
- img.onload = () => {
28875
- const canvas = document.createElement('canvas');
28876
- canvas.width = img.width * scale;
28877
- canvas.height = img.height * scale;
28878
- const ctx = canvas.getContext('2d');
28879
- if (!ctx) {
28880
- URL.revokeObjectURL(url);
28881
- reject(new Error('Failed to get canvas context'));
28882
- return;
28883
- }
28884
- ctx.scale(scale, scale);
28885
- ctx.drawImage(img, 0, 0);
28886
- URL.revokeObjectURL(url);
28887
- resolve(canvas.toDataURL('image/png'));
28888
- };
28889
- img.onerror = () => {
28890
- URL.revokeObjectURL(url);
28891
- reject(new Error('Failed to load SVG for PNG conversion'));
28892
- };
28893
- img.src = url;
28894
- });
28895
- }
28896
- /**
28897
- * Exports the currently selected objects as a PNG file and triggers a browser download.
28898
- * @param options - Optional export settings (theme, padding, scale for resolution).
28899
- */
28900
- async exportSelectedObjectsAsPng(options) {
28901
28897
  try {
28902
- const dataUrl = await this.getSelectedObjectsAsPngDataUrl(options);
28898
+ const dataUrl = await selected.exportAs(format, this.getObjectExportContext());
28903
28899
  if (!dataUrl) {
28904
- console.warn('No objects selected for PNG export');
28900
+ console.error(`Failed to export selected object as ${format}: export returned no data URL`);
28905
28901
  return;
28906
28902
  }
28903
+ const activeWorkspaceName = this.core.store.state?.activeWorkspace?.name || 'workspace';
28904
+ const objectName = selected.__class__.replace(/^Kritzel/, '').toLowerCase() || 'object';
28907
28905
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
28908
- const filename = `selection-${timestamp}.png`;
28909
- const link = document.createElement('a');
28910
- link.download = filename;
28911
- link.href = dataUrl;
28912
- link.click();
28906
+ const filename = `${activeWorkspaceName}-${objectName}-${timestamp}.${format}`;
28907
+ this.triggerDataUrlDownload(dataUrl, filename);
28913
28908
  }
28914
28909
  catch (error) {
28915
- console.error('Failed to export selected objects as PNG:', error);
28910
+ console.error(`Failed to export selected object as ${format}:`, error);
28916
28911
  }
28917
28912
  }
28918
28913
  /**
@@ -29107,6 +29102,73 @@ const KritzelEngine = class {
29107
29102
  const response = await fetch(dataUrl);
29108
29103
  return response.blob();
29109
29104
  }
29105
+ triggerDataUrlDownload(dataUrl, filename) {
29106
+ const link = document.createElement('a');
29107
+ link.download = filename;
29108
+ link.href = dataUrl;
29109
+ link.click();
29110
+ }
29111
+ getSingleSelectedExportableObject() {
29112
+ const selectedObjects = this.core.store.selectionGroup?.objects ?? [];
29113
+ if (selectedObjects.length !== 1) {
29114
+ return null;
29115
+ }
29116
+ const [selected] = selectedObjects;
29117
+ if (!schema_constants.ObjectHelper.isKritzelExportable(selected)) {
29118
+ return null;
29119
+ }
29120
+ return selected;
29121
+ }
29122
+ getObjectExportContext() {
29123
+ return {
29124
+ getObjectAsSvgDataUrl: async (object, options) => {
29125
+ const svg = await this.getObjectAsSvgMarkup(object, options?.rasterSafeForCanvas ?? false);
29126
+ return svg ? this.svgMarkupToDataUrl(svg) : null;
29127
+ },
29128
+ getObjectAsPngDataUrl: async (object) => {
29129
+ const svg = await this.getObjectAsSvgMarkup(object, true);
29130
+ return svg ? this.svgMarkupToPngDataUrl(svg) : null;
29131
+ },
29132
+ };
29133
+ }
29134
+ async getObjectAsSvgMarkup(object, rasterSafeForCanvas) {
29135
+ const imageDataUrls = await KritzelSvgExportHelper.resolveImageDataUrls([object], this.core.assetResolver);
29136
+ const svg = KritzelSvgExportHelper.generateSvg([object], {
29137
+ theme: this.theme,
29138
+ rasterSafeForCanvas,
29139
+ imageDataUrls,
29140
+ });
29141
+ return svg || null;
29142
+ }
29143
+ svgMarkupToDataUrl(svgMarkup) {
29144
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgMarkup)}`;
29145
+ }
29146
+ async svgMarkupToPngDataUrl(svgMarkup) {
29147
+ const svgBlob = new Blob([svgMarkup], { type: 'image/svg+xml;charset=utf-8' });
29148
+ const svgBlobUrl = URL.createObjectURL(svgBlob);
29149
+ try {
29150
+ const img = await new Promise((resolve, reject) => {
29151
+ const element = new Image();
29152
+ element.onload = () => resolve(element);
29153
+ element.onerror = err => reject(err);
29154
+ element.src = svgBlobUrl;
29155
+ });
29156
+ const width = Math.max(1, Math.ceil(img.naturalWidth || img.width));
29157
+ const height = Math.max(1, Math.ceil(img.naturalHeight || img.height));
29158
+ const canvas = document.createElement('canvas');
29159
+ canvas.width = width;
29160
+ canvas.height = height;
29161
+ const context = canvas.getContext('2d');
29162
+ if (!context) {
29163
+ throw new Error('Could not create 2D canvas context for PNG export');
29164
+ }
29165
+ context.drawImage(img, 0, 0, width, height);
29166
+ return canvas.toDataURL('image/png');
29167
+ }
29168
+ finally {
29169
+ URL.revokeObjectURL(svgBlobUrl);
29170
+ }
29171
+ }
29110
29172
  getImportedWorkspaceName(rawName) {
29111
29173
  if (typeof rawName === 'string' && rawName.trim().length > 0) {
29112
29174
  return rawName;
@@ -29983,7 +30045,9 @@ const KritzelEngine = class {
29983
30045
  this.emitObjectsInViewportChange();
29984
30046
  }
29985
30047
  }
29986
- return (index.h(index.Host, null, index.h("kritzel-loading-overlay", { visible: this.core.store.state.isLoading, text: this.core.localizationManager.translate('engine.loading') }), index.h("kritzel-sync-indicator", { visible: this.isSyncing, text: "Syncing..." }), this.core.store.state.debugInfo.showViewportInfo && (index.h("div", { class: "debug-panel" }, index.h("div", null, "ActiveWorkspaceId: ", this.core.store.state?.activeWorkspace?.id), index.h("div", null, "ActiveWorkspaceName: ", this.core.store.state?.activeWorkspace?.name), index.h("div", null, "TranslateX: ", this.core.store.state?.translateX), index.h("div", null, "TranslateY: ", this.core.store.state?.translateY), index.h("div", null, "ViewportWidth: ", this.core.store.state?.viewportWidth), index.h("div", null, "ViewportHeight: ", this.core.store.state?.viewportHeight), index.h("div", null, "PointerCount: ", this.core.store.state.pointers.size), index.h("div", null, "Scale: ", this.core.store.state?.scale), index.h("div", null, "ActiveTool: ", this.core.store.state?.activeTool?.name), index.h("div", null, "HasViewportChanged: ", this.core.store.state?.hasViewportChanged ? 'true' : 'false'), index.h("div", null, "IsEnabled: ", this.core.store.state?.isEnabled ? 'true' : 'false'), index.h("div", null, "IsScaling: ", this.core.store.state?.isScaling ? 'true' : 'false'), index.h("div", null, "IsPanning: ", this.core.store.state?.isPanning ? 'true' : 'false'), index.h("div", null, "IsSelecting: ", this.isSelecting ? 'true' : 'false'), index.h("div", null, "IsSelectionActive: ", this.isSelectionActive ? 'true' : 'false'), index.h("div", null, "IsResizeHandleSelected: ", this.core.store.state.isResizeHandleSelected ? 'true' : 'false'), index.h("div", null, "IsRotationHandleSelected: ", this.core.store.state.isRotationHandleSelected ? 'true' : 'false'), index.h("div", null, "IsRotationHandleHovered: ", this.core.store.state.isRotationHandleHovered ? 'true' : 'false'), index.h("div", null, "IsDrawing: ", this.core.store.state.isDrawing ? 'true' : 'false'), index.h("div", null, "IsWriting: ", this.core.store.state.isWriting ? 'true' : 'false'), index.h("div", null, "IsPointerDown: ", this.core.store.isPointerDown ? 'true' : 'false'), index.h("div", null, "PointerX: ", this.core.store.state?.pointerX), index.h("div", null, "PointerY: ", this.core.store.state?.pointerY), index.h("div", null, "TotalObjects: ", this.core.store.totalObjectCount), index.h("div", null, "ObjectsInViewport: ", this.core.store.objectsInViewport.length), index.h("div", null, "SelectedObjects: ", this.core.store.selectionGroup?.objects.length || 0), index.h("div", null, "ViewportCenter: (", viewportCenterX.toFixed(2), ", ", viewportCenterY.toFixed(2), ")"))), index.h("div", { id: "origin", class: "origin", style: {
30048
+ return (index.h(index.Host, null, index.h("kritzel-loading-overlay", { visible: this.core.store.state.isLoading, text: this.core.localizationManager.translate('engine.loading') }), index.h("kritzel-sync-indicator", { visible: this.isSyncing, text: "Syncing..." }), this.core.store.state.debugInfo.showViewportInfo && (index.h("div", { class: "debug-panel", style: {
30049
+ color: 'var(--kritzel-global-text-primary)',
30050
+ } }, index.h("div", null, "ActiveWorkspaceId: ", this.core.store.state?.activeWorkspace?.id), index.h("div", null, "ActiveWorkspaceName: ", this.core.store.state?.activeWorkspace?.name), index.h("div", null, "TranslateX: ", this.core.store.state?.translateX), index.h("div", null, "TranslateY: ", this.core.store.state?.translateY), index.h("div", null, "ViewportWidth: ", this.core.store.state?.viewportWidth), index.h("div", null, "ViewportHeight: ", this.core.store.state?.viewportHeight), index.h("div", null, "PointerCount: ", this.core.store.state.pointers.size), index.h("div", null, "Scale: ", this.core.store.state?.scale), index.h("div", null, "ActiveTool: ", this.core.store.state?.activeTool?.name), index.h("div", null, "HasViewportChanged: ", this.core.store.state?.hasViewportChanged ? 'true' : 'false'), index.h("div", null, "IsEnabled: ", this.core.store.state?.isEnabled ? 'true' : 'false'), index.h("div", null, "IsScaling: ", this.core.store.state?.isScaling ? 'true' : 'false'), index.h("div", null, "IsPanning: ", this.core.store.state?.isPanning ? 'true' : 'false'), index.h("div", null, "IsSelecting: ", this.isSelecting ? 'true' : 'false'), index.h("div", null, "IsSelectionActive: ", this.isSelectionActive ? 'true' : 'false'), index.h("div", null, "IsResizeHandleSelected: ", this.core.store.state.isResizeHandleSelected ? 'true' : 'false'), index.h("div", null, "IsRotationHandleSelected: ", this.core.store.state.isRotationHandleSelected ? 'true' : 'false'), index.h("div", null, "IsRotationHandleHovered: ", this.core.store.state.isRotationHandleHovered ? 'true' : 'false'), index.h("div", null, "IsDrawing: ", this.core.store.state.isDrawing ? 'true' : 'false'), index.h("div", null, "IsWriting: ", this.core.store.state.isWriting ? 'true' : 'false'), index.h("div", null, "IsPointerDown: ", this.core.store.isPointerDown ? 'true' : 'false'), index.h("div", null, "PointerX: ", this.core.store.state?.pointerX), index.h("div", null, "PointerY: ", this.core.store.state?.pointerY), index.h("div", null, "TotalObjects: ", this.core.store.totalObjectCount), index.h("div", null, "ObjectsInViewport: ", this.core.store.objectsInViewport.length), index.h("div", null, "SelectedObjects: ", this.core.store.selectionGroup?.objects.length || 0), index.h("div", null, "ViewportCenter: (", viewportCenterX.toFixed(2), ", ", viewportCenterY.toFixed(2), ")"))), index.h("div", { id: "origin", class: "origin", style: {
29987
30051
  transform: `matrix(${this.core.store.state?.scale}, 0, 0, ${this.core.store.state?.scale}, ${this.core.store.state?.translateX}, ${this.core.store.state?.translateY})`,
29988
30052
  opacity: this._sceneBootstrapOpacity.toString(),
29989
30053
  transition: this._sceneBootstrapTransitionEnabled ? 'opacity 220ms ease-out' : 'none',
@@ -30166,6 +30230,7 @@ const KritzelEngine = class {
30166
30230
  left: `${object.totalWidth}px`,
30167
30231
  top: '0',
30168
30232
  zIndex: (object.zIndex + 2).toString(),
30233
+ color: 'var(--kritzel-global-text-primary)',
30169
30234
  } }, index.h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "Id: ", object.id), index.h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "userId: ", object.userId), index.h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "width: ", object.width), index.h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "height: ", object.height), index.h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "translateX: ", object.translateX), index.h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "translateY: ", object.translateY), index.h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "scale: ", object.scale), index.h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "rotationDegrees: ", object.rotationDegrees), index.h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "zIndex: ", object.zIndex), index.h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "isSelected: ", object.isSelected ? 'true' : 'false'), schema_constants.KritzelClassHelper.isInstanceOf(object, 'KritzelCustomElement') && (index.h("div", null, index.h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "isInteractive: ", object.isInteractive ? 'true' : 'false'))), schema_constants.KritzelClassHelper.isInstanceOf(object, 'KritzelImage') && (index.h("div", null, index.h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "assetId: ", object.assetId), index.h("div", { style: { whiteSpace: 'nowrap', fontSize: '10px' } }, "mimeType: ", object.mimeType))))), (this.core.displaySelectionGroupUI(object) || this.core.displaySelectionLineUI(object)) &&
30170
30235
  (() => {
30171
30236
  const isSelectionGroup = schema_constants.KritzelClassHelper.isInstanceOf(object, 'KritzelSelectionGroup');
@@ -30366,8 +30431,12 @@ const KritzelEngine = class {
30366
30431
  strokeDasharray: data.dashArray,
30367
30432
  strokeLinecap: 'round',
30368
30433
  } }))), index.h("circle", { cx: data.centerX, cy: data.centerY, r: data.indicatorRadius, style: {
30369
- fill: isActiveSnap ? 'var(--kritzel-snap-indicator-fill, rgba(59, 130, 246, 0.3))' : 'rgba(59, 130, 246, 0.18)',
30370
- stroke: isActiveSnap ? 'var(--kritzel-snap-indicator-stroke, #007bff)' : 'rgba(59, 130, 246, 0.5)',
30434
+ fill: isActiveSnap
30435
+ ? 'var(--kritzel-snap-indicator-fill, rgba(59, 130, 246, 0.3))'
30436
+ : 'var(--kritzel-snap-indicator-fill-inactive, var(--kritzel-snap-indicator-fill, rgba(59, 130, 246, 0.18)))',
30437
+ stroke: isActiveSnap
30438
+ ? 'var(--kritzel-snap-indicator-stroke, #007bff)'
30439
+ : 'var(--kritzel-snap-indicator-stroke-inactive, var(--kritzel-snap-indicator-stroke, rgba(59, 130, 246, 0.5)))',
30371
30440
  strokeWidth: data.indicatorStrokeWidth,
30372
30441
  } }))));
30373
30442
  })(), (() => {
@@ -30571,7 +30640,7 @@ const KritzelExport = class {
30571
30640
  return (index.h("div", { class: "export-tab-content" }, index.h("kritzel-input", { label: this.terms['export.filename.label'] ?? 'Filename', value: this.exportFilename, placeholder: this.terms['export.filename.placeholder'] ?? 'Enter filename', suffix: ".json", onValueChange: this.handleFilenameChange })));
30572
30641
  }
30573
30642
  render() {
30574
- return (index.h(index.Host, { key: '0bc45e80716be4a7b19ae12a6464a641b5bd4550' }, index.h("kritzel-dialog", { key: 'f336f8b1e4ae10954d48ca67da88504faf06b295', isOpen: this.isDialogOpen, dialogTitle: this.terms['export.dialogTitle'] ?? 'Export', closable: true, contained: true, onDialogClose: this.closeDialog }, index.h("div", { key: 'e2d1209652bb54fb7f623e50bc4bbbb703083b76', class: "export-content" }, index.h("kritzel-pill-tabs", { key: 'dd78430b78d1e8c4ac0a76e8a6a5d48bebf5e889', tabs: this.tabs, value: this.activeTab, onValueChange: this.handleTabChange }), this.activeTab === 'viewport' && this.renderViewportExport(), this.activeTab === 'workspace' && this.renderWorkspaceExport(), index.h("button", { key: 'e06564889d991afc7a4ac43bb1e5504e98c94c52', class: "export-primary-button", onClick: this.handleExport }, this.terms['export.exportButton'] ?? 'Export')))));
30643
+ return (index.h(index.Host, { key: '0bc45e80716be4a7b19ae12a6464a641b5bd4550' }, index.h("kritzel-dialog", { key: 'f336f8b1e4ae10954d48ca67da88504faf06b295', isOpen: this.isDialogOpen, dialogTitle: this.terms['export.dialogTitle'] ?? 'Export', closable: true, contained: true, onDialogClose: this.closeDialog }, index.h("div", { key: 'e2d1209652bb54fb7f623e50bc4bbbb703083b76', class: "export-content" }, index.h("kritzel-pill-tabs", { key: 'dd78430b78d1e8c4ac0a76e8a6a5d48bebf5e889', tabs: this.tabs, value: this.activeTab, onValueChange: this.handleTabChange }), this.activeTab === 'viewport' && this.renderViewportExport(), this.activeTab === 'workspace' && this.renderWorkspaceExport()), index.h("div", { key: 'd04221d9f98a066d538f98f8c9409c558fdfa29f', slot: "footer" }, index.h("button", { key: '5cbb062756f29a897088d6647c6b8eae9f5781e2', class: "export-primary-button", onClick: this.handleExport }, this.terms['export.exportButton'] ?? 'Export')))));
30575
30644
  }
30576
30645
  };
30577
30646
  KritzelExport.style = kritzelExportCss();
@@ -30845,7 +30914,7 @@ const KritzelLoadingOverlay = class {
30845
30914
  */
30846
30915
  text = 'Loading...';
30847
30916
  render() {
30848
- return (index.h(index.Host, { key: '331e4c9eb30e6f7b98023c23218b3caf5093c34b' }, index.h("div", { key: 'f994fec0db325a2bd687e5acc68263752e7a6acd', class: { 'loading-overlay': true, visible: this.visible }, "aria-hidden": this.visible ? 'false' : 'true' }, index.h("span", { key: 'b6087dc1e59845ab95946e654c7bb8e568b42dda', class: "loading-spinner" }), this.text)));
30917
+ return (index.h(index.Host, { key: 'ce2edf698db335518979f597ad32fa259c3055b9' }, index.h("div", { key: '7cf019929b72d18fdd1cea55157dc5b5fc2ea151', class: { 'loading-overlay': true, visible: this.visible }, "aria-hidden": this.visible ? 'false' : 'true' }, index.h("span", { key: '86b7cb040bcfb1abb2161a38f9cff153d5aa6c18', class: "loading-spinner" }), this.text)));
30849
30918
  }
30850
30919
  };
30851
30920
  KritzelLoadingOverlay.style = kritzelLoadingOverlayCss();
@@ -30897,7 +30966,7 @@ const KritzelLoginDialog = class {
30897
30966
  this.dialogClosed.emit();
30898
30967
  };
30899
30968
  render() {
30900
- return (index.h(index.Host, { key: 'bcd7f30bbfeb07bbca732c5f1034bcabbd5e4f80' }, index.h("kritzel-dialog", { key: '61b5c5054d43433894747f37c571e68ecf08fc4c', dialogTitle: this.dialogTitle, isOpen: this.isDialogOpen, onDialogClose: this.closeDialog, size: "small", contained: true }, index.h("div", { key: '3ddcb858b6148e8be4bd121b2bd8ca38c66e0505', class: "login-content" }, this.subtitle && (index.h("p", { key: '6f10f890af9f6025a3aa2d69f8f4d916821fd653', class: "login-subtitle" }, this.subtitle)), index.h("div", { key: '0d043ac1ac751dc3d0a561bc16e52a2cfaffc578', class: "login-providers" }, this.providers.map(provider => (index.h("button", { key: provider.name, class: {
30969
+ return (index.h(index.Host, { key: '561b4e76017e71d99f05cd33ed9f6e3769bb9aec' }, index.h("kritzel-dialog", { key: '74b904f67496debebf7a1854e3e3dd193f14a445', dialogTitle: this.dialogTitle, isOpen: this.isDialogOpen, onDialogClose: this.closeDialog, size: "small", contained: true }, index.h("div", { key: 'dcc2a024349fcdfcad7c7c6d5b7754e5ae990ae3', class: "login-content" }, this.subtitle && (index.h("p", { key: 'fdc927c84294a2ac92aec90001c3da373cc5f082', class: "login-subtitle" }, this.subtitle)), index.h("div", { key: 'a6e74ff9b3211df195b05edd2be0ce69d1b128df', class: "login-providers" }, this.providers.map(provider => (index.h("button", { key: provider.name, class: {
30901
30970
  'provider-button': true,
30902
30971
  'is-loading': this.loadingProvider === provider.name,
30903
30972
  'is-disabled': this.loadingProvider !== null && this.loadingProvider !== provider.name,
@@ -31242,6 +31311,14 @@ const KritzelMoreMenu = class {
31242
31311
  itemSelect;
31243
31312
  menuAnchor = null;
31244
31313
  isTouchDevice = schema_constants.KritzelDevicesHelper.isTouchDevice();
31314
+ visibleItems = [];
31315
+ visibilityResolveVersion = 0;
31316
+ onItemsChange() {
31317
+ void this.resolveVisibleItems();
31318
+ }
31319
+ componentWillLoad() {
31320
+ void this.resolveVisibleItems();
31321
+ }
31245
31322
  toggleMenu = (event) => {
31246
31323
  event.stopPropagation();
31247
31324
  if (this.menuAnchor) {
@@ -31250,13 +31327,84 @@ const KritzelMoreMenu = class {
31250
31327
  else {
31251
31328
  window.dispatchEvent(new CustomEvent('kritzel-dismiss-menus'));
31252
31329
  this.menuAnchor = this.host;
31330
+ this.visibleItems = this.filterVisibleItemsSync(this.items ?? []);
31331
+ void this.resolveVisibleItems();
31253
31332
  }
31254
31333
  };
31255
31334
  closeMenu = () => {
31256
31335
  this.menuAnchor = null;
31257
31336
  };
31258
- get visibleItems() {
31259
- return this.items.filter(item => item.isVisible !== false);
31337
+ async evaluateVisibility(visibility) {
31338
+ if (typeof visibility === 'function') {
31339
+ try {
31340
+ return (await visibility()) !== false;
31341
+ }
31342
+ catch {
31343
+ return false;
31344
+ }
31345
+ }
31346
+ return visibility !== false;
31347
+ }
31348
+ evaluateVisibilitySync(visibility) {
31349
+ if (typeof visibility === 'function') {
31350
+ try {
31351
+ const result = visibility();
31352
+ return result instanceof Promise ? null : result !== false;
31353
+ }
31354
+ catch {
31355
+ return false;
31356
+ }
31357
+ }
31358
+ return visibility !== false;
31359
+ }
31360
+ filterVisibleItemsSync(items) {
31361
+ return items.reduce((visibleItems, item) => {
31362
+ const isVisible = this.evaluateVisibilitySync(item.isVisible);
31363
+ if (isVisible === null || !isVisible) {
31364
+ return visibleItems;
31365
+ }
31366
+ if (!item.children?.length) {
31367
+ visibleItems.push(item);
31368
+ return visibleItems;
31369
+ }
31370
+ visibleItems.push({
31371
+ ...item,
31372
+ children: this.filterVisibleItemsSync(item.children),
31373
+ });
31374
+ return visibleItems;
31375
+ }, []);
31376
+ }
31377
+ async filterVisibleItems(items) {
31378
+ const resolved = await Promise.all(items.map(async (item) => {
31379
+ const isVisible = await this.evaluateVisibility(item.isVisible);
31380
+ if (!isVisible) {
31381
+ return null;
31382
+ }
31383
+ if (!item.children?.length) {
31384
+ return item;
31385
+ }
31386
+ return {
31387
+ ...item,
31388
+ children: await this.filterVisibleItems(item.children),
31389
+ };
31390
+ }));
31391
+ return resolved.filter((item) => !!item);
31392
+ }
31393
+ async resolveVisibleItems() {
31394
+ const version = ++this.visibilityResolveVersion;
31395
+ const items = this.items ?? [];
31396
+ const resolvedItems = await this.filterVisibleItems(items);
31397
+ if (version !== this.visibilityResolveVersion) {
31398
+ return;
31399
+ }
31400
+ this.visibleItems = resolvedItems;
31401
+ }
31402
+ resolveMenuLabels(items) {
31403
+ return items.map(item => ({
31404
+ ...item,
31405
+ label: this.terms[item.label] ?? item.label,
31406
+ children: item.children ? this.resolveMenuLabels(item.children) : undefined,
31407
+ }));
31260
31408
  }
31261
31409
  handleMenuItemSelect = (event) => {
31262
31410
  const { item, parent } = event.detail;
@@ -31267,8 +31415,13 @@ const KritzelMoreMenu = class {
31267
31415
  this.closeMenu();
31268
31416
  };
31269
31417
  render() {
31270
- return (index.h(index.Host, { key: '96451062021ac2ebcb44b65384a7e9be67f1563b', class: { mobile: this.isTouchDevice }, style: { display: this.visible ? '' : 'none' } }, index.h("div", { key: 'e776398742284ea6984925d4a5a99e732674d640', class: { 'more-menu-wrapper': true, visible: this.visible } }, index.h("button", { key: '4371b2e5cbf7d35fac09a06ee2e54cd49072929b', class: "more-menu-button", "data-testid": "more-menu-button", onClick: this.toggleMenu, "aria-label": this.terms['moreMenu.ariaLabel'] ?? 'More options' }, index.h("kritzel-icon", { key: '076513ce393222b6f604036ec646a8309bc3a3ac', name: this.icon, size: this.iconSize })), index.h("kritzel-portal", { key: 'fde8071b3199735cc7827a2d6f2eaf79d0e1b997', anchor: this.menuAnchor, offsetY: this.offsetY, onClose: this.closeMenu }, index.h("kritzel-menu", { key: '2d49ad8a228c4b55a34c388b794bfdf500fec3c8', items: this.visibleItems, onItemSelect: this.handleMenuItemSelect })))));
31418
+ return (index.h(index.Host, { key: 'dbfe312eccd24901ffc68179ef93ba0a94a765c5', class: { mobile: this.isTouchDevice }, style: { display: this.visible ? '' : 'none' } }, index.h("div", { key: '33bd535bb58ae32a32d44133a109fdd3667b4947', class: { 'more-menu-wrapper': true, visible: this.visible } }, index.h("button", { key: '23dbae51d73b92b181721bdef4a44049ec6484c5', class: "more-menu-button", "data-testid": "more-menu-button", onClick: this.toggleMenu, "aria-label": this.terms['moreMenu.ariaLabel'] ?? 'More options' }, index.h("kritzel-icon", { key: 'bda7c092b57af366b5d6b882cbab991cd9bb77ba', name: this.icon, size: this.iconSize })), index.h("kritzel-portal", { key: '2959ac3e6e0a5598c735ce7a01619bda8753039f', anchor: this.menuAnchor, offsetY: this.offsetY, onClose: this.closeMenu }, index.h("kritzel-menu", { key: 'a5481a1bc02dc059dc0ad188b93b9eaf9b56e98f', items: this.resolveMenuLabels(this.visibleItems), onItemSelect: this.handleMenuItemSelect })))));
31271
31419
  }
31420
+ static get watchers() { return {
31421
+ "items": [{
31422
+ "onItemsChange": 0
31423
+ }]
31424
+ }; }
31272
31425
  };
31273
31426
  KritzelMoreMenu.style = kritzelMoreMenuCss();
31274
31427
 
@@ -31840,7 +31993,7 @@ const KritzelPortal = class {
31840
31993
  * This file is auto-generated by the version bump scripts.
31841
31994
  * Do not modify manually.
31842
31995
  */
31843
- const KRITZEL_VERSION = '0.4.7';
31996
+ const KRITZEL_VERSION = '0.4.9';
31844
31997
 
31845
31998
  const kritzelSettingsCss = () => `:host{display:contents}kritzel-dialog{--kritzel-dialog-body-padding:0;--kritzel-dialog-width-large:800px;--kritzel-dialog-height-large:500px}.footer-button{padding:8px 16px;border-radius:6px;cursor:pointer;font-size:14px}.cancel-button{border:1px solid #ebebeb;background:#fff;color:inherit}.cancel-button:hover{background:#f5f5f5}.settings-tab-panel{display:none}.settings-tab-panel.is-active{display:block}.settings-content{padding:0}.settings-content h3{margin:0 0 16px 0;font-size:18px;font-weight:600;color:var(--kritzel-settings-content-heading-color, #333333)}.settings-content p{margin:0;font-size:14px;color:var(--kritzel-settings-content-text-color, #666666);line-height:1.5}.settings-group{display:flex;flex-direction:column;gap:24px}.settings-item{display:flex;flex-direction:column;gap:8px}.settings-row{display:flex;align-items:center;justify-content:space-between;gap:16px}.settings-label{font-size:14px;font-weight:600;color:var(--kritzel-settings-label-color, #333333);margin:0 0 4px 0}.settings-description{font-size:12px;color:var(--kritzel-settings-description-color, #888888);margin:0;line-height:1.4}.shortcuts-list{display:flex;flex-direction:column;gap:24px}.shortcuts-category{display:flex;flex-direction:column;gap:8px}.shortcuts-category-title{font-size:14px;font-weight:600;color:var(--kritzel-settings-label-color, #333333);margin:0 0 4px 0}.shortcuts-group{display:flex;flex-direction:column;gap:4px}.shortcut-item{display:flex;justify-content:space-between;align-items:center;padding:6px 8px;border-radius:4px;background:var(--kritzel-settings-shortcut-item-bg, rgba(0, 0, 0, 0.02))}.shortcut-label{font-size:14px;color:var(--kritzel-settings-content-text-color, #666666)}.shortcut-key{font-family:monospace;font-size:12px;padding:2px 8px;border-radius:4px;background:var(--kritzel-settings-shortcut-key-bg, #f0f0f0);color:var(--kritzel-settings-shortcut-key-color, #333333);border:1px solid var(--kritzel-settings-shortcut-key-border, #ddd)}`;
31846
31999
 
@@ -32052,7 +32205,7 @@ const KritzelSettings = class {
32052
32205
  return (index.h("div", { class: "settings-categories-wrapper" }, index.h("div", { class: { 'settings-tab-panel': true, 'is-active': this.selectedCategoryId === 'general' } }, index.h("div", { class: "settings-content" }, index.h("h3", null, this.t('settings.general.title', 'General Settings')), index.h("div", { class: "settings-group" }, index.h("div", { class: "settings-item" }, index.h("label", { class: "settings-label" }, this.t('settings.general.theme.label', 'Theme')), index.h("p", { class: "settings-description" }, this.t('settings.general.theme.description', 'Select a registered color theme for the editor interface.')), index.h("kritzel-dropdown", { options: this.availableThemes.map(t => ({ value: t, label: t })), value: this.theme, onValueChanged: this.handleThemeChange })), this.availableLocales.length > 0 && (index.h("div", { class: "settings-item" }, index.h("label", { class: "settings-label" }, this.t('settings.general.language.label', 'Language')), index.h("p", { class: "settings-description" }, this.t('settings.general.language.description', 'Select the display language for the editor interface.')), index.h("kritzel-dropdown", { options: this.availableLocales.map(l => ({ value: l.code, label: l.label })), value: this.locale, onValueChanged: this.handleLocaleChange }))), index.h("div", { class: "settings-item" }, index.h("label", { class: "settings-label" }, this.t('settings.general.lockDrawingScale.label', 'Lock Drawing Scale')), index.h("p", { class: "settings-description" }, this.t('settings.general.lockDrawingScale.description', 'When enabled, drawn objects maintain a fixed visual size regardless of the current zoom level.')), index.h("kritzel-slide-toggle", { checked: this.lockDrawingScale, label: this.t('settings.general.lockDrawingScale.label', 'Lock Drawing Scale'), onCheckedChange: this.handleLockDrawingScaleChange }))))), index.h("div", { class: { 'settings-tab-panel': true, 'is-active': this.selectedCategoryId === 'viewport' } }, index.h("div", { class: "settings-content" }, index.h("h3", null, this.t('settings.viewport.title', 'Viewport Settings')), index.h("div", { class: "settings-group" }, index.h("div", { class: "settings-item" }, index.h("label", { class: "settings-label" }, this.t('settings.viewport.minZoom.label', 'Minimum Zoom Level')), index.h("p", { class: "settings-description" }, this.t('settings.viewport.minZoom.description', 'Sets the minimum zoom level. Lower values allow zooming out further to see more of the canvas.')), index.h("kritzel-numeric-input", { value: this.scaleMin, min: 0.0001, max: 1, step: 0.0001, onValueChange: this.handleScaleMinChange })), index.h("div", { class: "settings-item" }, index.h("label", { class: "settings-label" }, this.t('settings.viewport.maxZoom.label', 'Maximum Zoom Level')), index.h("p", { class: "settings-description" }, this.t('settings.viewport.maxZoom.description', 'Sets the maximum zoom level. Higher values allow zooming in closer for detailed work.')), index.h("kritzel-numeric-input", { value: this.scaleMax, min: 1, max: 1000, step: 1, onValueChange: this.handleScaleMaxChange })), index.h("div", { class: "settings-item" }, index.h("label", { class: "settings-label" }, this.t('settings.viewport.boundaryLeft.label', 'Viewport Boundary Left')), index.h("p", { class: "settings-description" }, this.t('settings.viewport.boundaryLeft.description', 'Left boundary in world coordinates. Set to limit how far left the viewport can pan.')), index.h("kritzel-numeric-input", { value: this.viewportBoundaryLeft, step: 100, placeholder: this.t('settings.viewport.boundaryPlaceholder', 'Infinite'), onValueChange: this.handleViewportBoundaryLeftChange })), index.h("div", { class: "settings-item" }, index.h("label", { class: "settings-label" }, this.t('settings.viewport.boundaryRight.label', 'Viewport Boundary Right')), index.h("p", { class: "settings-description" }, this.t('settings.viewport.boundaryRight.description', 'Right boundary in world coordinates. Set to limit how far right the viewport can pan.')), index.h("kritzel-numeric-input", { value: this.viewportBoundaryRight, step: 100, placeholder: this.t('settings.viewport.boundaryPlaceholder', 'Infinite'), onValueChange: this.handleViewportBoundaryRightChange })), index.h("div", { class: "settings-item" }, index.h("label", { class: "settings-label" }, this.t('settings.viewport.boundaryTop.label', 'Viewport Boundary Top')), index.h("p", { class: "settings-description" }, this.t('settings.viewport.boundaryTop.description', 'Top boundary in world coordinates. Set to limit how far up the viewport can pan.')), index.h("kritzel-numeric-input", { value: this.viewportBoundaryTop, step: 100, placeholder: this.t('settings.viewport.boundaryPlaceholder', 'Infinite'), onValueChange: this.handleViewportBoundaryTopChange })), index.h("div", { class: "settings-item" }, index.h("label", { class: "settings-label" }, this.t('settings.viewport.boundaryBottom.label', 'Viewport Boundary Bottom')), index.h("p", { class: "settings-description" }, this.t('settings.viewport.boundaryBottom.description', 'Bottom boundary in world coordinates. Set to limit how far down the viewport can pan.')), index.h("kritzel-numeric-input", { value: this.viewportBoundaryBottom, step: 100, placeholder: this.t('settings.viewport.boundaryPlaceholder', 'Infinite'), onValueChange: this.handleViewportBoundaryBottomChange }))))), index.h("div", { class: { 'settings-tab-panel': true, 'is-active': this.selectedCategoryId === 'shortcuts' } }, index.h("div", { class: "settings-content" }, index.h("h3", null, this.t('settings.shortcuts.title', 'Keyboard Shortcuts')), index.h("div", { class: "shortcuts-list" }, Array.from(this.groupShortcutsByCategory()).map(([category, shortcuts]) => (index.h("div", { class: "shortcuts-category", key: category }, index.h("h4", { class: "shortcuts-category-title" }, category), index.h("div", { class: "shortcuts-group" }, shortcuts.map(shortcut => (index.h("div", { class: "shortcut-item", key: shortcut.key + shortcut.label }, index.h("span", { class: "shortcut-label" }, shortcut.label), index.h("kbd", { class: "shortcut-key" }, this.formatKeyCombo(shortcut)))))))))))), index.h("div", { class: { 'settings-tab-panel': true, 'is-active': this.selectedCategoryId === 'developer' } }, index.h("div", { class: "settings-content" }, index.h("h3", null, this.t('settings.developer.title', 'Developer Options')), index.h("div", { class: "settings-group" }, index.h("div", { class: "settings-item" }, index.h("label", { class: "settings-label" }, this.t('settings.developer.showViewportInfo.label', 'Show Viewport Info')), index.h("p", { class: "settings-description" }, this.t('settings.developer.showViewportInfo.description', 'Display viewport debug information such as position, zoom level, and boundaries.')), index.h("kritzel-slide-toggle", { checked: this.debugInfo.showViewportInfo, label: this.t('settings.developer.showViewportInfo.label', 'Show Viewport Info'), onCheckedChange: this.handleDebugInfoChange('showViewportInfo') })), index.h("div", { class: "settings-item" }, index.h("label", { class: "settings-label" }, this.t('settings.developer.showObjectInfo.label', 'Show Object Info')), index.h("p", { class: "settings-description" }, this.t('settings.developer.showObjectInfo.description', 'Display debug information about objects on the canvas.')), index.h("kritzel-slide-toggle", { checked: this.debugInfo.showObjectInfo, label: this.t('settings.developer.showObjectInfo.label', 'Show Object Info'), onCheckedChange: this.handleDebugInfoChange('showObjectInfo') })), index.h("div", { class: "settings-item" }, index.h("label", { class: "settings-label" }, this.t('settings.developer.showSyncProviderInfo.label', 'Show Sync Provider Info')), index.h("p", { class: "settings-description" }, this.t('settings.developer.showSyncProviderInfo.description', 'Display debug information about the sync provider connection status.')), index.h("kritzel-slide-toggle", { checked: this.debugInfo.showSyncProviderInfo, label: this.t('settings.developer.showSyncProviderInfo.label', 'Show Sync Provider Info'), onCheckedChange: this.handleDebugInfoChange('showSyncProviderInfo') })), index.h("div", { class: "settings-item" }, index.h("label", { class: "settings-label" }, this.t('settings.developer.showMigrationInfo.label', 'Show Migration Info')), index.h("p", { class: "settings-description" }, this.t('settings.developer.showMigrationInfo.description', 'Display debug information about data migrations.')), index.h("kritzel-slide-toggle", { checked: this.debugInfo.showMigrationInfo, label: this.t('settings.developer.showMigrationInfo.label', 'Show Migration Info'), onCheckedChange: this.handleDebugInfoChange('showMigrationInfo') }))))), index.h("div", { class: { 'settings-tab-panel': true, 'is-active': this.selectedCategoryId === 'about' } }, index.h("div", { class: "settings-content" }, index.h("h3", null, this.t('settings.about.title', 'About')), index.h("p", null, this.t('settings.about.description', 'Kritzel - A drawing application')), index.h("p", { class: "version-info" }, "Version ", KRITZEL_VERSION), index.h("p", { class: "version-info" }, "App-State Schema v", schema_constants.CURRENT_APP_STATE_SCHEMA_VERSION), index.h("p", { class: "version-info" }, "Workspace Schema v", schema_constants.CURRENT_WORKSPACE_SCHEMA_VERSION)))));
32053
32206
  }
32054
32207
  render() {
32055
- return (index.h(index.Host, { key: '36bca8be04b34a37630d6aa5394595a8b394d180' }, index.h("kritzel-dialog", { key: '1e257904178fd9afa2bfbe4a758663fb366c8847', isOpen: this.isDialogOpen, dialogTitle: this.t('settings.dialogTitle', 'Settings'), size: "large", contained: true, onDialogClose: this.closeDialog }, index.h("kritzel-master-detail", { key: 'b7d9ad97d9b126ccf6e800e6c9032749635eb988', items: this.categories, selectedItemId: this.selectedCategoryId, onItemSelect: this.handleCategorySelect }, this.renderCategoryContent()))));
32208
+ return (index.h(index.Host, { key: 'b8a899abd3dd55bc3493b7d124ac5505e2284036' }, index.h("kritzel-dialog", { key: '8d47f3548271e24a8a280a30139f04b2e4b4ec2b', isOpen: this.isDialogOpen, dialogTitle: this.t('settings.dialogTitle', 'Settings'), size: "large", contained: true, onDialogClose: this.closeDialog }, index.h("kritzel-master-detail", { key: 'a5213307761b84b8f5f67cb35409ab1c0c1fa57b', items: this.categories, selectedItemId: this.selectedCategoryId, onItemSelect: this.handleCategorySelect }, this.renderCategoryContent()))));
32056
32209
  }
32057
32210
  static get watchers() { return {
32058
32211
  "settings": [{
@@ -35292,9 +35445,9 @@ const KritzelShareDialog = class {
35292
35445
  this.dialogClosed.emit();
35293
35446
  };
35294
35447
  render() {
35295
- return (index.h(index.Host, { key: 'd30a468b9443f76731b207cb5caec1911f378c15' }, index.h("kritzel-dialog", { key: '4a4ec0a2becba58a49a87fc411a82e26557d8232', dialogTitle: this.terms['share.dialogTitle'] ?? 'Share Workspace', size: "small", isOpen: this.isDialogOpen, onDialogClose: this.closeDialog, contained: true }, index.h("div", { key: '0b2fec3db6c039752429768fb4bd6a2150a4345e', class: "share-content" }, index.h("div", { key: 'bd0b85f8418d0c46d14b718024ed62b9f72e4f52', class: "share-section" }, index.h("div", { key: 'bf62503a593454ffacd30d532fcd79dcbf938659', class: "share-row" }, index.h("div", { key: 'dfd9e83166b886d00fb5b097d0ccd77169c011bd', class: "share-label-group" }, index.h("label", { key: '67dba950ded47a08eb006c1628d14038b18171b5', class: "share-label" }, this.terms['share.linkSharing.label'] ?? 'Link sharing'), index.h("p", { key: '926f8670b35dab1e3e2a6496f44b5d6e0ee46417', class: "share-description" }, this.internalIsPublic
35448
+ return (index.h(index.Host, { key: '75deaa66e6a6f640506985a8b3a2746892b5da07' }, index.h("kritzel-dialog", { key: '237413c9302e3756c5f7653e69326fac80d9e181', dialogTitle: this.terms['share.dialogTitle'] ?? 'Share Workspace', size: "small", isOpen: this.isDialogOpen, onDialogClose: this.closeDialog, contained: true }, index.h("div", { key: '295394b851cc2210fece6f3e297dfbc0c516b0f7', class: "share-content" }, index.h("div", { key: 'df64d71f9c7a7064d36db6d0a196071f1e8835a9', class: "share-section" }, index.h("div", { key: '9db4902a5edd917efb5ad6c183f0862b12313635', class: "share-row" }, index.h("div", { key: '8ead1d363d3430a4e65fa3b5835401b0e20006db', class: "share-label-group" }, index.h("label", { key: '2f3c28a3909eab86a123ba6cf7386b79894717c5', class: "share-label" }, this.terms['share.linkSharing.label'] ?? 'Link sharing'), index.h("p", { key: '3167ae1ed6ff52abc531eafdd905f1397807f551', class: "share-description" }, this.internalIsPublic
35296
35449
  ? (this.terms['share.linkSharing.enabledDescription'] ?? 'Anyone with the link can access this workspace.')
35297
- : (this.terms['share.linkSharing.disabledDescription'] ?? 'Link sharing is disabled. Only you can access this workspace.'))), index.h("kritzel-slide-toggle", { key: 'e42fabecb37fd21cc4907b966e2f50113b945eda', checked: this.internalIsPublic, onCheckedChange: this.handleToggleChange, label: this.terms['share.linkSharing.toggleLabel'] ?? 'Enable link sharing' }))), this.internalIsPublic && (index.h("div", { key: '40afacc81e0bbcf4c404ae32491e5142a32bca66', class: "share-section" }, index.h("div", { key: '57b6952670b1cbc789770362b573623035b50cf3', class: "share-url-container" }, index.h("input", { key: '0ee836655ca26c1e073d5d86014322939527c74b', type: "text", class: "share-url-input", value: this.getShareUrl(), readOnly: true, onClick: (e) => e.target.select() }), index.h("button", { key: '2d4661c1718bd8390dbe1146e8f9afe035934635', class: { 'copy-button': true, 'copy-success': this.copySuccess }, onClick: this.handleCopyUrl, title: this.copySuccess ? (this.terms['share.copyLink.copied'] ?? 'Copied!') : (this.terms['share.copyLink.title'] ?? 'Copy link') }, index.h("kritzel-icon", { key: 'b43bde94601c352b6065c89c026807d7efb3bcc3', name: this.copySuccess ? 'check' : 'copy', size: 18 }))), this.qrCodeDataUrl && (index.h("div", { key: 'f4d657aa918abbb3cf3ed039332d8c67247b83e8', class: "share-qr-container" }, index.h("img", { key: 'b165a446df3fba87918a19928734d39af1e6bfcf', class: "share-qr-image", src: this.qrCodeDataUrl, alt: "QR code for share link" })))))))));
35450
+ : (this.terms['share.linkSharing.disabledDescription'] ?? 'Link sharing is disabled. Only you can access this workspace.'))), index.h("kritzel-slide-toggle", { key: '3ff608b262e2ff3fa779f7d1e9a4ee3c0b9a16c0', checked: this.internalIsPublic, onCheckedChange: this.handleToggleChange, label: this.terms['share.linkSharing.toggleLabel'] ?? 'Enable link sharing' }))), this.internalIsPublic && (index.h("div", { key: '355261384b0ed386f3b48c041bcf0039db0534c1', class: "share-section" }, index.h("div", { key: 'ad3d975c650757d0b6dbd5317c9bac4f98029e0c', class: "share-url-container" }, index.h("input", { key: '0da2cc9cb30f1da8bcab3aa82c679b8d685641bd', type: "text", class: "share-url-input", value: this.getShareUrl(), readOnly: true, onClick: (e) => e.target.select() }), index.h("button", { key: 'aee232b7a70b524d52a4b4cf9074c099d29a5300', class: { 'copy-button': true, 'copy-success': this.copySuccess }, onClick: this.handleCopyUrl, title: this.copySuccess ? (this.terms['share.copyLink.copied'] ?? 'Copied!') : (this.terms['share.copyLink.title'] ?? 'Copy link') }, index.h("kritzel-icon", { key: 'c151baf7b890dbf51c4f4831d60b0ebb1a5d05fa', name: this.copySuccess ? 'check' : 'copy', size: 18 }))), this.qrCodeDataUrl && (index.h("div", { key: '63b43efe3dc5929054af9e5ee7f64d093864b3b0', class: "share-qr-container" }, index.h("img", { key: '9d3b0418eb80ff8c7e9106ad86d7cc211d017504', class: "share-qr-image", src: this.qrCodeDataUrl, alt: "QR code for share link" })))))))));
35298
35451
  }
35299
35452
  static get watchers() { return {
35300
35453
  "isPublic": [{
@@ -35936,7 +36089,7 @@ const KritzelUtilityPanel = class {
35936
36089
  this.redo.emit();
35937
36090
  }
35938
36091
  render() {
35939
- return (index.h(index.Host, { key: '3e86e2c4c480ab56f4646a4f288ad37a63e29642' }, index.h("button", { key: 'e5282e42994f2de061ec36bf892f4da3fc01d2e5', class: "utility-button", "data-testid": "utility-undo", disabled: !this.undoState?.canUndo, onClick: event => this.handleUndo(event), "aria-label": this.terms['utility.undo'] ?? 'Undo' }, index.h("kritzel-icon", { key: '5a2b903cc3e5f9c554928f11a1c3098d569cbc4d', name: "undo" })), index.h("button", { key: '466cdff71501f34f0236b4dc60263817966d7b4a', class: "utility-button", "data-testid": "utility-redo", disabled: !this.undoState?.canRedo, onClick: event => this.handleRedo(event), "aria-label": this.terms['utility.redo'] ?? 'Redo' }, index.h("kritzel-icon", { key: '19209a95787f4475eda9dc0712c2bf36a573c953', name: "redo" })), index.h("div", { key: '7a463d5ac1f78017fb728abcd2c33061d8545978', class: "utility-separator" }), index.h("button", { key: '155c2bb50d70223e4f4d5377bacced56ffda68ad', class: "utility-button", "data-testid": "utility-delete", onClick: () => this.delete.emit(), "aria-label": this.terms['utility.delete'] ?? 'Delete selected items' }, index.h("kritzel-icon", { key: '47bd8469b9efe395e9185b3e7375c7375811c0aa', name: "delete" }))));
36092
+ return (index.h(index.Host, { key: '001ee010d714513a4b5027f84cac4586c64e2260' }, index.h("button", { key: '6ba6cbf100e2ad9f81de6416b85f981cd1467f1c', class: "utility-button", "data-testid": "utility-undo", disabled: !this.undoState?.canUndo, onClick: event => this.handleUndo(event), "aria-label": this.terms['utility.undo'] ?? 'Undo' }, index.h("kritzel-icon", { key: 'b246d3964623e57e9458e5f0266a2f9e0565c646', name: "undo" })), index.h("button", { key: '1f5015fbd2fd84508bc8b8f295d35960bf31af5b', class: "utility-button", "data-testid": "utility-redo", disabled: !this.undoState?.canRedo, onClick: event => this.handleRedo(event), "aria-label": this.terms['utility.redo'] ?? 'Redo' }, index.h("kritzel-icon", { key: 'b3694d884707c6844a0036610990cdcfa74c6931', name: "redo" })), index.h("div", { key: '4f8f4725213d1453af11dc3b4c9ff271f1933df5', class: "utility-separator" }), index.h("button", { key: '5eba2c56dd96698538a48a8ea9f32fcf8b4d8402', class: "utility-button", "data-testid": "utility-delete", onClick: () => this.delete.emit(), "aria-label": this.terms['utility.delete'] ?? 'Delete selected items' }, index.h("kritzel-icon", { key: '268a186e4e7eaa1ea874ef23e72df511f86b0343', name: "delete" }))));
35940
36093
  }
35941
36094
  };
35942
36095
  KritzelUtilityPanel.style = kritzelUtilityPanelCss();
@@ -36145,7 +36298,7 @@ const KritzelZoomPanel = class {
36145
36298
  return Number.isFinite(this.zoomPercent) ? Math.max(1, Math.round(this.zoomPercent)) : 100;
36146
36299
  }
36147
36300
  render() {
36148
- return (index.h(index.Host, { key: '054e24893ed609c29b02cf574a68cf9311a3f025' }, index.h("div", { key: '42ad21dca9486ae350d5814b949e0c577a6ed996', class: { panel: true, visible: this.visible } }, index.h("button", { key: 'e360884e5a2edaada46d232cae8121f2c5f45bb6', class: "zoom-button", type: "button", "aria-label": this.terms['zoom.zoomOut'] ?? 'Zoom out', disabled: this.disabled, onClick: this.handleZoomOut }, index.h("kritzel-icon", { key: '8c8bb23c4ec4b78fd04aee5c0edd3371c4d14125', name: "minus" })), index.h("span", { key: '42ab93551c2fbb6d8d4ef076d66405277ba941bf', class: "zoom-level", "aria-live": "polite" }, this.normalizedZoomPercent, "%"), index.h("button", { key: '232f0f74b748999c6892549978bbe085636ca189', class: "zoom-button", type: "button", "aria-label": this.terms['zoom.zoomIn'] ?? 'Zoom in', disabled: this.disabled, onClick: this.handleZoomIn }, index.h("kritzel-icon", { key: '6f9b8ddd4c666573d9716d7bc455c2a3b1035ede', name: "plus" })))));
36301
+ return (index.h(index.Host, { key: 'aa4a0194b7ba89a32e369ae5ce9a8c148c05574f' }, index.h("div", { key: 'c88d4b299210d2aff28e07815bcbd4864ff13afd', class: { panel: true, visible: this.visible } }, index.h("button", { key: 'bf2c78d32ba31c25dcfed0b1f87fbfaee776acc1', class: "zoom-button", type: "button", "aria-label": this.terms['zoom.zoomOut'] ?? 'Zoom out', disabled: this.disabled, onClick: this.handleZoomOut }, index.h("kritzel-icon", { key: '3d350d003ed9d0b4548929d0765aae5da82c41b0', name: "minus" })), index.h("span", { key: 'da6454d4c28b5bdde41300c21b6593d91ce08085', class: "zoom-level", "aria-live": "polite" }, this.normalizedZoomPercent, "%"), index.h("button", { key: 'cf314fb20dcff5b3e728eae2129978cb12942947', class: "zoom-button", type: "button", "aria-label": this.terms['zoom.zoomIn'] ?? 'Zoom in', disabled: this.disabled, onClick: this.handleZoomIn }, index.h("kritzel-icon", { key: '71ca263815fd1774d5511118d8217863adcac64b', name: "plus" })))));
36149
36302
  }
36150
36303
  };
36151
36304
  KritzelZoomPanel.style = kritzelZoomPanelCss();