@bagelink/vue 1.16.0 → 1.17.6

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 (128) hide show
  1. package/dist/assetTransport.d.ts +28 -0
  2. package/dist/assetTransport.d.ts.map +1 -0
  3. package/dist/assetTransport.test.d.ts +2 -0
  4. package/dist/assetTransport.test.d.ts.map +1 -0
  5. package/dist/assets.d.ts +65 -0
  6. package/dist/assets.d.ts.map +1 -0
  7. package/dist/assets.test.d.ts +2 -0
  8. package/dist/assets.test.d.ts.map +1 -0
  9. package/dist/components/AddressSearch.vue.d.ts +40 -36
  10. package/dist/components/AddressSearch.vue.d.ts.map +1 -1
  11. package/dist/components/ContextMenu.vue.d.ts +518 -0
  12. package/dist/components/ContextMenu.vue.d.ts.map +1 -0
  13. package/dist/components/Dropdown.types.d.ts +25 -0
  14. package/dist/components/Dropdown.types.d.ts.map +1 -0
  15. package/dist/components/Dropdown.vue.d.ts +20 -15
  16. package/dist/components/Dropdown.vue.d.ts.map +1 -1
  17. package/dist/components/FilterQuery.vue.d.ts +1 -1
  18. package/dist/components/FilterQuery.vue.d.ts.map +1 -1
  19. package/dist/components/Icon/Icon.vue.d.ts +0 -1
  20. package/dist/components/Icon/Icon.vue.d.ts.map +1 -1
  21. package/dist/components/Icon/constants.d.ts +0 -1
  22. package/dist/components/Icon/constants.d.ts.map +1 -1
  23. package/dist/components/Icon/types.d.ts +2 -3
  24. package/dist/components/Icon/types.d.ts.map +1 -1
  25. package/dist/components/Image.vue.d.ts +7 -1
  26. package/dist/components/Image.vue.d.ts.map +1 -1
  27. package/dist/components/Swiper.vue.d.ts +1 -1
  28. package/dist/components/draggable/useDraggable.d.ts.map +1 -1
  29. package/dist/components/form/inputs/IconPickerInput.vue.d.ts +48 -37
  30. package/dist/components/form/inputs/IconPickerInput.vue.d.ts.map +1 -1
  31. package/dist/components/form/inputs/RichText/components/EditorToolbar.vue.d.ts.map +1 -1
  32. package/dist/components/form/inputs/RichText/composables/useCommands.d.ts.map +1 -1
  33. package/dist/components/form/inputs/RichText/composables/useEditor.d.ts +3 -0
  34. package/dist/components/form/inputs/RichText/composables/useEditor.d.ts.map +1 -1
  35. package/dist/components/form/inputs/RichText/composables/useEditorKeyboard.d.ts +1 -1
  36. package/dist/components/form/inputs/RichText/composables/useEditorKeyboard.d.ts.map +1 -1
  37. package/dist/components/form/inputs/RichText/index.vue.d.ts.map +1 -1
  38. package/dist/components/form/inputs/RichText/utils/commands.d.ts.map +1 -1
  39. package/dist/components/form/inputs/RichText/utils/selection.d.ts +2 -0
  40. package/dist/components/form/inputs/RichText/utils/selection.d.ts.map +1 -1
  41. package/dist/components/form/inputs/RichText/utils/table.d.ts +34 -8
  42. package/dist/components/form/inputs/RichText/utils/table.d.ts.map +1 -1
  43. package/dist/components/form/inputs/SelectInput.vue.d.ts +71 -63
  44. package/dist/components/form/inputs/SelectInput.vue.d.ts.map +1 -1
  45. package/dist/components/form/inputs/TelInput.vue.d.ts +2 -2
  46. package/dist/components/form/inputs/Upload/AssetUploadInput.vue.d.ts +11 -0
  47. package/dist/components/form/inputs/Upload/AssetUploadInput.vue.d.ts.map +1 -0
  48. package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts +2 -2
  49. package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts.map +1 -1
  50. package/dist/components/index.d.ts +3 -1
  51. package/dist/components/index.d.ts.map +1 -1
  52. package/dist/components/lightbox/AssetPreview.vue.d.ts +9 -0
  53. package/dist/components/lightbox/AssetPreview.vue.d.ts.map +1 -0
  54. package/dist/components/lightbox/Lightbox.vue.d.ts.map +1 -1
  55. package/dist/components/lightbox/LightboxImage.vue.d.ts +2 -1
  56. package/dist/components/lightbox/LightboxImage.vue.d.ts.map +1 -1
  57. package/dist/components/lightbox/index.d.ts.map +1 -1
  58. package/dist/components/lightbox/lightbox.types.d.ts +7 -0
  59. package/dist/components/lightbox/lightbox.types.d.ts.map +1 -1
  60. package/dist/composables/index.d.ts +1 -1
  61. package/dist/composables/index.d.ts.map +1 -1
  62. package/dist/composables/useBoundedRectDrag.d.ts.map +1 -1
  63. package/dist/composables/useImageSrc.d.ts +8 -4
  64. package/dist/composables/useImageSrc.d.ts.map +1 -1
  65. package/dist/composables/usePointerSession.d.ts +14 -3
  66. package/dist/composables/usePointerSession.d.ts.map +1 -1
  67. package/dist/core.css +3 -3
  68. package/dist/directives/vResize.d.ts.map +1 -1
  69. package/dist/form-flow/FormFlowPlayer.vue.d.ts +3 -3
  70. package/dist/form-flow/FormFlowPlayer.vue.d.ts.map +1 -1
  71. package/dist/form-flow/form-flow.d.ts +13 -6
  72. package/dist/form-flow/form-flow.d.ts.map +1 -1
  73. package/dist/form-flow/schema-fields.d.ts +6 -0
  74. package/dist/form-flow/schema-fields.d.ts.map +1 -1
  75. package/dist/index.cjs +240 -153
  76. package/dist/index.css +1 -1
  77. package/dist/index.d.ts +3 -0
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.mjs +24167 -23417
  80. package/dist/style.css +3 -3
  81. package/dist/types/BagelForm.d.ts +5 -1
  82. package/dist/types/BagelForm.d.ts.map +1 -1
  83. package/package.json +1 -1
  84. package/src/assetTransport.test.ts +75 -0
  85. package/src/assetTransport.ts +98 -0
  86. package/src/assets.test.ts +82 -0
  87. package/src/assets.ts +162 -0
  88. package/src/components/ContextMenu.vue +113 -0
  89. package/src/components/Dropdown.types.ts +27 -0
  90. package/src/components/Dropdown.vue +129 -64
  91. package/src/components/FilterQuery.vue +1 -1
  92. package/src/components/Icon/Icon.vue +8 -30
  93. package/src/components/Icon/constants.ts +0 -166
  94. package/src/components/Icon/types.ts +2 -3
  95. package/src/components/Image.vue +19 -3
  96. package/src/components/draggable/useDraggable.ts +67 -100
  97. package/src/components/form/inputs/IconPickerInput.vue +14 -9
  98. package/src/components/form/inputs/RichText/components/EditorToolbar.vue +43 -111
  99. package/src/components/form/inputs/RichText/composables/useCommands.ts +32 -54
  100. package/src/components/form/inputs/RichText/composables/useEditor.ts +45 -171
  101. package/src/components/form/inputs/RichText/composables/useEditorKeyboard.ts +90 -14
  102. package/src/components/form/inputs/RichText/index.vue +1195 -1210
  103. package/src/components/form/inputs/RichText/utils/commands.ts +689 -1028
  104. package/src/components/form/inputs/RichText/utils/selection.ts +66 -41
  105. package/src/components/form/inputs/RichText/utils/table.ts +798 -136
  106. package/src/components/form/inputs/SelectInput.vue +1 -1
  107. package/src/components/form/inputs/Upload/AssetUploadInput.vue +67 -0
  108. package/src/components/form/inputs/Upload/UploadInput.vue +5 -1
  109. package/src/components/index.ts +3 -1
  110. package/src/components/lightbox/AssetPreview.vue +16 -0
  111. package/src/components/lightbox/Lightbox.vue +8 -3
  112. package/src/components/lightbox/LightboxImage.vue +4 -3
  113. package/src/components/lightbox/index.ts +29 -4
  114. package/src/components/lightbox/lightbox.types.ts +5 -1
  115. package/src/composables/index.ts +1 -1
  116. package/src/composables/useBoundedRectDrag.ts +9 -12
  117. package/src/composables/useImageSrc.ts +47 -37
  118. package/src/composables/usePointerSession.ts +57 -12
  119. package/src/directives/vResize.ts +6 -7
  120. package/src/form-flow/form-flow.ts +31 -7
  121. package/src/form-flow/schema-fields.ts +28 -1
  122. package/src/index.ts +3 -0
  123. package/src/styles/core.css +1 -18
  124. package/src/types/BagelForm.ts +5 -1
  125. package/dist/components/form/inputs/RichText/utils/formatting.d.ts +0 -10
  126. package/dist/components/form/inputs/RichText/utils/formatting.d.ts.map +0 -1
  127. package/src/components/form/inputs/RichText/components/gridBox.vue +0 -58
  128. package/src/components/form/inputs/RichText/utils/formatting.ts +0 -331
@@ -17,6 +17,10 @@ import {
17
17
  isCustomFormFieldType,
18
18
  warnMissingCustomFormField,
19
19
  } from './custom-field-registry'
20
+ import type {
21
+ JSONSchemaObject as FormJSONSchemaObject,
22
+ JSONSchemaProperty as FormJSONSchemaProperty,
23
+ } from './schema-fields'
20
24
 
21
25
  // ============================================================================
22
26
  // Core Types
@@ -412,6 +416,9 @@ export const $ = {
412
416
  noFilePlaceholder?: string
413
417
  btnPlaceholder?: string
414
418
  style?: string
419
+ /** Upload to the immutable assets catalog and persist a `{asset_id}` pin. */
420
+ assetMode?: boolean
421
+ assetVisibility?: import('../assets').AssetVisibility
415
422
  }),
416
423
  config?: BaseFieldConfig & {
417
424
  multiple?: boolean
@@ -428,6 +435,9 @@ export const $ = {
428
435
  noFilePlaceholder?: string
429
436
  btnPlaceholder?: string
430
437
  style?: string
438
+ /** Upload to the immutable assets catalog and persist a `{asset_id}` pin. */
439
+ assetMode?: boolean
440
+ assetVisibility?: import('../assets').AssetVisibility
431
441
  }
432
442
  ): FieldBuilder<string | string[]> {
433
443
  return new Field('upload', parseArgs(labelOrConfig, config))
@@ -806,8 +816,10 @@ export function multiStepSchema<T extends Record<string, any> = Record<string, a
806
816
  // JSONSchema Import
807
817
  // ============================================================================
808
818
 
809
- interface JSONSchemaProperty {
810
- 'type'?: string
819
+ type JSONSchemaProperty = Omit<
820
+ FormJSONSchemaProperty,
821
+ 'items' | 'properties' | 'x-field-type'
822
+ > & {
811
823
  'title'?: string
812
824
  'description'?: string
813
825
  'default'?: any
@@ -855,6 +867,7 @@ interface JSONSchemaProperty {
855
867
  'x-placeholder'?: string
856
868
  'x-select-multiselect'?: boolean
857
869
  'x-select-searchable'?: boolean
870
+ 'x-asset'?: boolean
858
871
  'x-radio-inline'?: boolean
859
872
  'x-radio-align'?: 'start' | 'center' | 'end'
860
873
  'x-radio-text-align'?: 'left' | 'center' | 'right'
@@ -929,8 +942,8 @@ function normalizeOptionsSourceResponse(
929
942
  })
930
943
  }
931
944
 
932
- interface JSONSchemaObject {
933
- 'type'?: string
945
+ type JSONSchemaObject = Omit<FormJSONSchemaObject, 'properties'> & {
946
+ 'type': 'object'
934
947
  'title'?: string
935
948
  'properties'?: Record<string, JSONSchemaProperty>
936
949
  'required'?: string[]
@@ -1039,8 +1052,8 @@ function resolveUnionType(prop: JSONSchemaProperty, _rootSchema: JSONSchemaObjec
1039
1052
  // Prefer object > array > string > number > boolean
1040
1053
  const typeOrder = ['object', 'array', 'string', 'number', 'integer', 'boolean']
1041
1054
  const sorted = nonNullTypes.sort((a, b) => {
1042
- const aIdx = typeOrder.indexOf(a.type || 'string')
1043
- const bIdx = typeOrder.indexOf(b.type || 'string')
1055
+ const aIdx = typeOrder.indexOf(Array.isArray(a.type) ? (a.type[0] ?? 'string') : (a.type ?? 'string'))
1056
+ const bIdx = typeOrder.indexOf(Array.isArray(b.type) ? (b.type[0] ?? 'string') : (b.type ?? 'string'))
1044
1057
  return aIdx - bIdx
1045
1058
  })
1046
1059
 
@@ -1092,8 +1105,12 @@ function createFieldFromProperty(
1092
1105
  const xUi = resolvedProp['x-ui'] || {}
1093
1106
  // Field type may be declared either as `x-ui.fieldType` (this module's own
1094
1107
  // round-trip) or the flat `x-field-type` (FormBuilder designer). Multiselect
1095
- // is a `select` flagged multi.
1108
+ // is a `select` flagged multi. `x-asset` is an immutable-assets file field
1109
+ // whose JSON-schema type is `object` (the pin), not `string`.
1096
1110
  let fieldType = xUi.fieldType || resolvedProp['x-field-type'] || inferFieldType(resolvedProp)
1111
+ if (resolvedProp['x-asset'] === true && fieldType !== 'file' && fieldType !== 'upload') {
1112
+ fieldType = 'file'
1113
+ }
1097
1114
  if (fieldType === 'select' && resolvedProp['x-select-multiselect']) fieldType = 'multiselect'
1098
1115
 
1099
1116
  // Designer-authored options: [{label,value}] pairs preserve labels (raw
@@ -1232,6 +1249,13 @@ function createFieldFromProperty(
1232
1249
  break
1233
1250
  case 'file':
1234
1251
  case 'upload':
1252
+ // `x-asset` marks a field as managed by the immutable assets catalog:
1253
+ // the widget uploads and persists a `{asset_id}` pin, and
1254
+ // the JSON-schema type is `object`, not `string`.
1255
+ if (resolvedProp['x-asset'] === true) config.assetMode = true
1256
+ if (resolvedProp['x-asset-visibility']) {
1257
+ config.assetVisibility = resolvedProp['x-asset-visibility']
1258
+ }
1235
1259
  field = $.upload(config)
1236
1260
  break
1237
1261
  case 'array': {
@@ -95,6 +95,10 @@ export interface FormField {
95
95
  fileTheme?: 'dropzone' | 'basic'
96
96
  fileShowIcon?: boolean
97
97
  fileIcon?: string
98
+ /** Upload to the immutable assets catalog; persist a `{asset_id}` pin. */
99
+ assetMode?: boolean
100
+ /** Asset delivery policy. Omitted assets are protected. */
101
+ assetVisibility?: import('../assets').AssetVisibility
98
102
  dateEnableTime?: boolean
99
103
  dateMin?: string
100
104
  dateMax?: string
@@ -163,6 +167,8 @@ export interface JSONSchemaProperty {
163
167
  'x-file-theme'?: 'dropzone' | 'basic'
164
168
  'x-file-show-icon'?: boolean
165
169
  'x-file-icon'?: string
170
+ 'x-asset'?: boolean
171
+ 'x-asset-visibility'?: import('../assets').AssetVisibility
166
172
  'x-date-enable-time'?: boolean
167
173
  'x-date-min'?: string
168
174
  'x-date-max'?: string
@@ -351,7 +357,28 @@ export function fieldsToSchema(
351
357
  prop.type = 'string'
352
358
  break
353
359
  case 'file':
354
- if (field.fileMultiple === true) {
360
+ if (field.assetMode === true) {
361
+ // Public-by-default assets catalog: persist a `{asset_id}` pin.
362
+ // The JSON-schema type is `object` (the pin), and `x-asset` is the
363
+ // authoritative marker — never a `string` URL.
364
+ const assetRef = {
365
+ type: 'object',
366
+ properties: {
367
+ asset_id: { type: 'string', format: 'uuid' },
368
+ },
369
+ required: ['asset_id'],
370
+ }
371
+ if (field.fileMultiple === true) {
372
+ prop.type = 'array'
373
+ prop.items = assetRef
374
+ } else {
375
+ Object.assign(prop, assetRef)
376
+ }
377
+ prop['x-asset'] = true
378
+ if (field.assetVisibility) {
379
+ prop['x-asset-visibility'] = field.assetVisibility
380
+ }
381
+ } else if (field.fileMultiple === true) {
355
382
  prop.type = 'array'
356
383
  prop.items = { type: 'string' }
357
384
  } else {
package/src/index.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  export * from './components'
2
+ export * from './assets'
3
+ export * from './assetTransport'
4
+ export { default as AssetPreview } from './components/lightbox/AssetPreview.vue'
2
5
  export * from './composables'
3
6
  // Dialog (native <dialog> based)
4
7
  export * from './dialog'
@@ -1,7 +1,7 @@
1
1
  /* Font-neutral Bagel UI stylesheet for public/customer sites.
2
2
  Apps choose their own typography and may import optional font packs. */
3
3
  @import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,100..700,0..1,0');
4
- @import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css');
4
+ @import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/brands.min.css');
5
5
 
6
6
  @import "theme.css";
7
7
  @import "dark.css";
@@ -27,7 +27,6 @@
27
27
  @import "color-variants.css";
28
28
 
29
29
  .bgl_icon-font { font-family: 'Material Symbols Outlined', serif !important; }
30
- .bgl_icon-font.fa { font-family: 'Font Awesome 6 Free', serif !important; }
31
30
  .bgl_icon-font.fa-brands { font-family: 'Font Awesome 6 Brands', serif !important; }
32
31
  .bgl_icon-font.bgl_icon-round {
33
32
  display: inline-flex;
@@ -94,19 +93,3 @@ button::-moz-focus-inner, input::-moz-focus-inner { border: 0; padding: 0; }
94
93
  font-weight: 400;
95
94
  src: url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/webfonts/fa-brands-400.woff2') format('woff2');
96
95
  }
97
-
98
- @font-face {
99
- font-display: swap;
100
- font-family: 'Font Awesome 6 Free';
101
- font-style: normal;
102
- font-weight: 400;
103
- src: url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/webfonts/fa-regular-400.woff2') format('woff2');
104
- }
105
-
106
- @font-face {
107
- font-display: swap;
108
- font-family: 'Font Awesome 6 Free';
109
- font-style: normal;
110
- font-weight: 900;
111
- src: url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/webfonts/fa-solid-900.woff2') format('woff2');
112
- }
@@ -192,6 +192,10 @@ export interface UploadInputValueObject {
192
192
  }
193
193
 
194
194
  export interface UploadInputProps extends BagelInputShellProps {
195
+ /** Opt-in stable reference output; defaults to legacy path-key behavior. */
196
+ assetMode?: boolean
197
+ /** Asset delivery policy. Defaults to protected; website/media fields use public. */
198
+ assetVisibility?: import('../assets').AssetVisibility
195
199
  id?: string
196
200
  name?: string
197
201
  label?: string
@@ -200,7 +204,7 @@ export interface UploadInputProps extends BagelInputShellProps {
200
204
  * Path-key string(s). Legacy/migrated records may also provide an image object
201
205
  * (e.g. `{ imageURL, imageName, extension, altText }`); it is normalized internally.
202
206
  */
203
- modelValue?: string | string[] | UploadInputValueObject | (string | UploadInputValueObject)[]
207
+ modelValue?: string | string[] | UploadInputValueObject | import('../assets').AssetReference | (string | UploadInputValueObject | import('../assets').AssetReference)[]
204
208
  /** Optional upload handler. When omitted, the shared file-storage uploader is used. */
205
209
  upload?: (file: File) => Promise<string>
206
210
  width?: string
@@ -1,10 +0,0 @@
1
- import { EditorState } from '../richTextTypes';
2
- export declare function formatting(state: EditorState): {
3
- text: (command: string) => void;
4
- block: (command: string, tag: string) => void;
5
- list: (command: string) => void;
6
- clear: () => void;
7
- };
8
- export declare function getBlockElement(node: Node): HTMLElement | null;
9
- export declare function adjustIndentation(state: EditorState, increase: boolean): void;
10
- //# sourceMappingURL=formatting.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"formatting.d.ts","sourceRoot":"","sources":["../../../../../../src/components/form/inputs/RichText/utils/formatting.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAGnD,wBAAgB,UAAU,CAAC,KAAK,EAAE,WAAW;oBAIrB,MAAM;qBA4CL,MAAM,OAAO,MAAM;oBA6CpB,MAAM;;EAsN7B;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,IAAI,GAAG,WAAW,GAAG,IAAI,CAI9D;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,WAAW,EAAE,QAAQ,EAAE,OAAO,QAYtE"}
@@ -1,58 +0,0 @@
1
- <script setup lang="ts">
2
- import { computed, ref } from 'vue'
3
-
4
- const props = defineProps<{
5
- gridSize?: number
6
- }>()
7
- const emit = defineEmits(['select'])
8
- const fb = 1
9
- const base = props.gridSize || 5
10
- const hoveredRow = ref(fb)
11
- const hoveredCol = ref(fb)
12
-
13
- const rowSize = computed(() => {
14
- const enlarge = Math.min(hoveredRow.value + 1, 20)
15
- return hoveredRow.value > (base - 1) ? enlarge : base
16
- })
17
- const colSize = computed(() => {
18
- const enlarge = Math.min(hoveredCol.value + 1, 20)
19
- return hoveredCol.value > (base - 1) ? enlarge : base
20
- })
21
- </script>
22
-
23
- <template>
24
- <div class="grid grid-wrap p-05">
25
- <div
26
- v-for="row in rowSize" :key="`row-${row}`" class="flex" @mouseout="hoveredRow = fb; hoveredCol = fb"
27
- @focusout="hoveredRow = fb; hoveredCol = fb"
28
- >
29
- <div
30
- v-for="col in colSize" :key="`col-${col}`" role="button" tabindex="0" aria-label="Insert Table"
31
- :style="{ width: '20px', height: '20px' }" class="flex pointer gap-025 tableBoxSelect"
32
- :class="{ borderSelect: hoveredRow >= row && hoveredCol >= col }"
33
- @mousemove="hoveredRow = row; hoveredCol = col" @click="emit('select', `${row}x${col}`)"
34
- />
35
- </div>
36
- <p class="txt-center txt-12 color-gray">
37
- {{ hoveredRow }}x{{ hoveredCol }}
38
- </p>
39
- </div>
40
- </template>
41
-
42
- <style scoped>
43
-
44
- .tableBoxSelect::after {
45
- content: '';
46
- display: block;
47
- width: calc(100% - 4px);
48
- height: calc(100% - 4px);
49
- border: 1px solid var(--bgl-gray);
50
- background: var(--bgl-gray-light);
51
-
52
- }
53
- .borderSelect::after {
54
- border-color: var(--bgl-primary-tint);
55
- background: var(--bgl-primary-light);
56
- }
57
-
58
- </style>
@@ -1,331 +0,0 @@
1
- import type { EditorState } from '../richTextTypes'
2
- import { analyzeSelection, restoreSelection, getBlocksBetween } from './selection'
3
-
4
- export function formatting(state: EditorState) {
5
- const { doc } = state
6
- if (!doc) { return { text: () => {}, block: () => {}, list: () => {}, clear: () => {} } }
7
-
8
- const text = (command: string) => {
9
- const selection = doc.getSelection()
10
- if (!selection || !selection.rangeCount) { return }
11
-
12
- const range = selection.getRangeAt(0)
13
- const container = range.commonAncestorContainer
14
- const parentBlock = container.nodeType === 3 ? container.parentElement : container as HTMLElement
15
-
16
- // Preserve RTL direction when applying text formatting
17
- const isRTL = parentBlock?.closest('[dir="rtl"]') !== null
18
-
19
- // Don't apply inline styles directly to block elements
20
- if (parentBlock?.tagName.match(/^H[1-6]|P|BLOCKQUOTE|LI$/)) {
21
- if (!range.collapsed && range.toString().trim()) {
22
- let element: HTMLElement
23
- if (command === 'underline') { element = doc.createElement('u') }
24
- else if (command === 'bold') { element = doc.createElement('b') }
25
- else if (command === 'italic') { element = doc.createElement('i') }
26
- else { return }
27
-
28
- if (isRTL) { element.dir = 'rtl' }
29
- range.surroundContents(element)
30
- }
31
- } else {
32
- if (range.collapsed) { return } // No selection, nothing to format
33
-
34
- let element: HTMLElement
35
- if (command === 'bold') { element = doc.createElement('b') }
36
- else if (command === 'italic') { element = doc.createElement('i') }
37
- else if (command === 'underline') { element = doc.createElement('u') }
38
- else { return }
39
-
40
- try {
41
- range.surroundContents(element)
42
- } catch {
43
- // If surroundContents fails (e.g., for selections across multiple nodes)
44
- // Extract the fragment, wrap it, and insert it back
45
- const fragment = range.extractContents()
46
- element.appendChild(fragment)
47
- range.insertNode(element)
48
- }
49
- }
50
- }
51
-
52
- const block = (command: string, tag: string) => {
53
- const selection = doc.getSelection()
54
- if (!selection || !selection.rangeCount) { return }
55
-
56
- const range = selection.getRangeAt(0)
57
- const container = range.commonAncestorContainer
58
- const parentBlock = container.nodeType === 3 ? container.parentElement : container as HTMLElement
59
- const isRTL = parentBlock?.closest('[dir="rtl"]') !== null
60
-
61
- // Remove any invalid inline style wrapping
62
- if (parentBlock?.closest('u, b, i')) {
63
- const wrapper = parentBlock.closest('u, b, i')
64
- if (wrapper) {
65
- const parent = wrapper.parentNode
66
- while (wrapper.firstChild) {
67
- parent?.insertBefore(wrapper.firstChild, wrapper)
68
- }
69
- wrapper.remove()
70
- }
71
- }
72
-
73
- // Create a new block element of the desired type
74
- const newBlock = doc.createElement(tag)
75
- if (isRTL) { newBlock.dir = 'rtl' }
76
-
77
- // Find the current block to replace
78
- const currentBlock = parentBlock?.closest('p,h1,h2,h3,h4,h5,h6,blockquote,div') || parentBlock
79
-
80
- if (currentBlock) {
81
- // Copy content to the new block
82
- while (currentBlock.firstChild) {
83
- newBlock.appendChild(currentBlock.firstChild)
84
- }
85
-
86
- // Replace the current block with the new one
87
- currentBlock.parentNode?.replaceChild(newBlock, currentBlock)
88
-
89
- // Move cursor into the new block
90
- range.selectNodeContents(newBlock)
91
- range.collapse(false)
92
- selection.removeAllRanges()
93
- selection.addRange(range)
94
- }
95
- }
96
-
97
- const list = (command: string) => {
98
- if (!state.doc || !state.range || !state.selection) { return }
99
-
100
- const listTag = command === 'orderedList' ? 'ol' : 'ul'
101
- const selectionInfo = analyzeSelection(state.doc, state.range)
102
- if (!selectionInfo) { return }
103
-
104
- const isRTL = selectionInfo.parent.closest('[dir="rtl"]') !== null
105
-
106
- // If we're inside a list item, handle toggling off or switching list type
107
- const listItem = selectionInfo.parent.closest('li')
108
- if (listItem) {
109
- const listParent = listItem.parentElement
110
- if (!listParent) { return }
111
-
112
- // If it's the same list type, toggle off
113
- if (listParent.tagName.toLowerCase() === listTag) {
114
- // Create a paragraph from the list item content
115
- const p = state.doc.createElement('p') as HTMLParagraphElement
116
- if (isRTL) { p.dir = 'rtl' }
117
- while (listItem.firstChild) {
118
- p.appendChild(listItem.firstChild)
119
- }
120
-
121
- listParent.parentNode?.insertBefore(p, listParent)
122
- listItem.remove()
123
-
124
- if (!listParent.querySelector('li')) {
125
- listParent.remove()
126
- }
127
-
128
- restoreSelection(state.doc, state.range, state.selection, selectionInfo, p)
129
- return
130
- }
131
-
132
- // Convert to the other list type
133
- const newList = state.doc.createElement(listTag) as HTMLElement
134
- if (isRTL) { newList.dir = 'rtl' }
135
- const allItems = Array.from(listParent.children)
136
- allItems.forEach(item => newList.appendChild(item.cloneNode(true)))
137
- listParent.parentNode?.replaceChild(newList, listParent)
138
-
139
- const newListItem = newList.children[Array.from(allItems).indexOf(listItem)]
140
- restoreSelection(state.doc, state.range, state.selection, selectionInfo, newListItem)
141
- return
142
- }
143
-
144
- // For single block conversion
145
- if (!selectionInfo.isMultiBlock) {
146
- const li = state.doc.createElement('li')
147
- const listEl = state.doc.createElement(listTag) as HTMLElement
148
- if (isRTL) { listEl.dir = 'rtl' }
149
-
150
- while (selectionInfo.startBlock.firstChild) {
151
- li.appendChild(selectionInfo.startBlock.firstChild)
152
- }
153
-
154
- listEl.appendChild(li)
155
- selectionInfo.startBlock.parentNode?.replaceChild(listEl, selectionInfo.startBlock)
156
- restoreSelection(state.doc, state.range, state.selection, selectionInfo, li)
157
- return
158
- }
159
-
160
- // Handle multi-block selection
161
- const blocks = getBlocksBetween(selectionInfo.startBlock, selectionInfo.endBlock)
162
- if (!blocks.length) { return }
163
-
164
- const listEl = state.doc.createElement(listTag) as HTMLElement
165
- if (isRTL) { listEl.dir = 'rtl' }
166
-
167
- const firstBlock = blocks[0]
168
- firstBlock.parentNode?.insertBefore(listEl, firstBlock)
169
-
170
- blocks.forEach((block) => {
171
- if (!state.doc) { return }
172
- const li = state.doc.createElement('li')
173
- while (block.firstChild) {
174
- li.appendChild(block.firstChild)
175
- }
176
- listEl.appendChild(li)
177
- block.remove()
178
- })
179
-
180
- restoreSelection(state.doc, state.range, state.selection, selectionInfo, listEl)
181
- }
182
-
183
- const clear = () => {
184
- console.log('[Clear Format] Starting clear format process', state)
185
- console.assert(!!state, '[Clear Format] State must exist')
186
- console.assert(!!state.doc, '[Clear Format] Document must exist')
187
- console.assert(!!state.range, '[Clear Format] Range must exist')
188
- console.assert(!!state.selection, '[Clear Format] Selection must exist')
189
-
190
- if (!state.doc || !state.range || !state.selection) {
191
- console.log('[Clear Format] No document or selection')
192
- return
193
- }
194
-
195
- const selectionInfo = analyzeSelection(state.doc, state.range)
196
- console.log('[Clear Format] Selection info:', selectionInfo)
197
- if (!selectionInfo) {
198
- console.log('[Clear Format] No valid selection info')
199
- return
200
- }
201
-
202
- // If selection is collapsed (just a cursor), return
203
- if (state.range.collapsed) {
204
- console.log('[Clear Format] Selection is collapsed, nothing to clear')
205
- return
206
- }
207
-
208
- console.log('[Clear Format] Processing selection:', {
209
- startContainer: state.range.startContainer,
210
- endContainer: state.range.endContainer,
211
- selectedText: state.range.toString()
212
- })
213
-
214
- // Get the selected content
215
- const fragment = state.range.cloneContents()
216
- const tempDiv = state.doc.createElement('div')
217
- tempDiv.appendChild(fragment)
218
-
219
- console.log('[Clear Format] Original HTML:', tempDiv.innerHTML)
220
-
221
- // Function to recursively clean a node and its children
222
- const cleanNode = (node: Node): Node => {
223
- if (!state.doc) { return node }
224
-
225
- // Text nodes can be returned as-is
226
- if (node.nodeType === 3) {
227
- return node.cloneNode(true)
228
- }
229
-
230
- if (node.nodeType === 1) { // Element node
231
- const el = node as HTMLElement
232
- const nodeName = el.nodeName.toLowerCase()
233
- const inlineTags = ['b', 'i', 'u', 'strong', 'em', 'span', 'font', 'strike', 'sub', 'sup']
234
-
235
- console.log('[Clear Format] Processing element:', nodeName, {
236
- hasStyle: el.hasAttribute('style'),
237
- style: el.getAttribute('style'),
238
- className: el.className
239
- })
240
-
241
- // For inline formatting elements, just extract the text content
242
- if (inlineTags.includes(nodeName)) {
243
- // Create a text node with the element's content
244
- const textContent = el.textContent || ''
245
- console.log('[Clear Format] Extracting text from inline element:', textContent)
246
- return state.doc.createTextNode(textContent)
247
- }
248
-
249
- // For block elements, preserve the element type but remove formatting
250
- const newEl = state.doc.createElement(nodeName)
251
-
252
- // Remove any style and class attributes
253
- if (el.hasAttribute('style')) {
254
- console.log('[Clear Format] Removing style attribute:', el.getAttribute('style'))
255
- }
256
- if (el.className) {
257
- console.log('[Clear Format] Removing class:', el.className)
258
- }
259
-
260
- // Transfer only specific attributes we want to keep (like href for links)
261
- if (nodeName === 'a' && el.hasAttribute('href')) {
262
- newEl.setAttribute('href', el.getAttribute('href') || '')
263
- if (el.hasAttribute('target')) {
264
- newEl.setAttribute('target', el.getAttribute('target') || '')
265
- }
266
- console.log('[Clear Format] Preserving link attributes')
267
- }
268
-
269
- // For images, preserve src and alt
270
- if (nodeName === 'img') {
271
- if (el.hasAttribute('src')) { newEl.setAttribute('src', el.getAttribute('src') || '') }
272
- if (el.hasAttribute('alt')) { newEl.setAttribute('alt', el.getAttribute('alt') || '') }
273
- console.log('[Clear Format] Preserving image attributes')
274
- }
275
-
276
- // Clean and append all child nodes
277
- Array.from(el.childNodes).forEach((child) => {
278
- newEl.appendChild(cleanNode(child))
279
- })
280
-
281
- return newEl
282
- }
283
-
284
- return node.cloneNode(true)
285
- }
286
-
287
- // Clean all nodes in the fragment
288
- const cleanedFragment = state.doc.createDocumentFragment()
289
- Array.from(tempDiv.childNodes).forEach((node) => {
290
- cleanedFragment.appendChild(cleanNode(node))
291
- })
292
-
293
- // For debugging: check what the cleaned HTML looks like
294
- const debugDiv = state.doc.createElement('div')
295
- debugDiv.appendChild(cleanedFragment.cloneNode(true))
296
- console.log('[Clear Format] Cleaned HTML:', debugDiv.innerHTML)
297
-
298
- // Replace the content
299
- state.range.deleteContents()
300
- state.range.insertNode(cleanedFragment)
301
-
302
- // Restore selection
303
- restoreSelection(state.doc, state.range, state.selection, selectionInfo)
304
-
305
- // Update content state
306
- state.content = state.doc.body.innerHTML
307
- console.log('[Clear Format] Updated document HTML:', state.content)
308
- }
309
-
310
- return { text, block, list, clear }
311
- }
312
-
313
- export function getBlockElement(node: Node): HTMLElement | null {
314
- const element = node.nodeType === 3 ? node.parentElement : node as HTMLElement
315
- if (!element) { return null }
316
- return element.closest('p,h1,h2,h3,h4,h5,h6,blockquote,li') || element
317
- }
318
-
319
- export function adjustIndentation(state: EditorState, increase: boolean) {
320
- const { doc, range } = state
321
- if (!doc || !range) { return }
322
-
323
- const blockElement = getBlockElement(range.commonAncestorContainer)
324
- if (!blockElement) { return }
325
-
326
- const currentMargin = Number.parseInt(blockElement.style.marginLeft || '0', 10)
327
- const step = 20 // pixels to indent/outdent
328
- const newMargin = increase ? currentMargin + step : Math.max(0, currentMargin - step)
329
-
330
- blockElement.style.marginLeft = `${newMargin}px`
331
- }