@pgcorp/ui-kit 0.1.0

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 (251) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -0
  3. package/docs/accessibility.md +27 -0
  4. package/docs/getting-started.md +80 -0
  5. package/docs/licensing.md +30 -0
  6. package/docs/public-api.md +39 -0
  7. package/docs/security.md +21 -0
  8. package/docs/theming.md +35 -0
  9. package/docs/versioning.md +23 -0
  10. package/index.ts +3 -0
  11. package/package.json +592 -0
  12. package/src/components/icons/_internal/generatedSputnigUiIconMarkup.ts +971 -0
  13. package/src/components/icons/sputnigUiIcons.ts +530 -0
  14. package/src/components/layout/SAppShell.vue +232 -0
  15. package/src/components/layout/SDockRegion.vue +200 -0
  16. package/src/components/layout/SResizeHandle.vue +251 -0
  17. package/src/components/layout/SSideMenuButton.vue +104 -0
  18. package/src/components/layout/SSideMenuRail.vue +47 -0
  19. package/src/components/layout/SStack.css +25 -0
  20. package/src/components/layout/SStack.vue +57 -0
  21. package/src/components/layout/SStatusBarShell.css +15 -0
  22. package/src/components/layout/SStatusBarShell.vue +26 -0
  23. package/src/components/layout/SWorkbenchLayout.vue +53 -0
  24. package/src/components/layout/SWorkbenchPanelShell.css +161 -0
  25. package/src/components/layout/SWorkbenchPanelShell.vue +286 -0
  26. package/src/components/layout/_internal/SAppBrand.css +45 -0
  27. package/src/components/layout/_internal/SAppBrand.vue +37 -0
  28. package/src/components/layout/_internal/SAppBrandContent.vue +30 -0
  29. package/src/components/layout/types.ts +28 -0
  30. package/src/components/shared/_internal/SDialogSurface.css +127 -0
  31. package/src/components/shared/_internal/SDialogSurface.vue +117 -0
  32. package/src/components/shared/_internal/SDisclosureSummary.css +92 -0
  33. package/src/components/shared/_internal/SDisclosureSummary.vue +121 -0
  34. package/src/components/shared/_internal/SListboxGroup.css +18 -0
  35. package/src/components/shared/_internal/SListboxGroup.vue +24 -0
  36. package/src/components/shared/_internal/SMenuItem.css +112 -0
  37. package/src/components/shared/_internal/SMenuItem.vue +213 -0
  38. package/src/components/shared/_internal/SMenuLayer.css +21 -0
  39. package/src/components/shared/_internal/SMenuLayer.vue +48 -0
  40. package/src/components/shared/_internal/SMenuSeparator.css +5 -0
  41. package/src/components/shared/_internal/SMenuSeparator.vue +13 -0
  42. package/src/components/shared/_internal/SToastItem.css +65 -0
  43. package/src/components/shared/_internal/SToastItem.vue +58 -0
  44. package/src/components/shared/_internal/STreeNode.vue +761 -0
  45. package/src/components/shared/_internal/dialogSurface.ts +105 -0
  46. package/src/components/shared/_internal/focusTrap.ts +40 -0
  47. package/src/components/shared/_internal/listboxContext.ts +16 -0
  48. package/src/components/shared/_internal/markerContract.ts +10 -0
  49. package/src/components/shared/_internal/menuContext.ts +23 -0
  50. package/src/components/shared/_internal/selectionContract.ts +239 -0
  51. package/src/components/shared/_internal/sidebarGroupContext.ts +31 -0
  52. package/src/components/shared/_internal/tabsContext.ts +57 -0
  53. package/src/components/shared/_internal/treeRuntime.ts +203 -0
  54. package/src/components/shared/_internal/useSelectionRoving.ts +162 -0
  55. package/src/components/shared/codeLanguages.ts +168 -0
  56. package/src/components/shared/complex/SKanbanCard.css +8 -0
  57. package/src/components/shared/complex/SKanbanCard.vue +76 -0
  58. package/src/components/shared/complex/SKanbanLane.vue +82 -0
  59. package/src/components/shared/complex/STree.css +24 -0
  60. package/src/components/shared/complex/STree.vue +288 -0
  61. package/src/components/shared/complex/treeAdapter.ts +210 -0
  62. package/src/components/shared/complex/types.ts +102 -0
  63. package/src/components/shared/containers/SCloudChrome.css +103 -0
  64. package/src/components/shared/containers/SCloudChrome.vue +87 -0
  65. package/src/components/shared/containers/SCloudChromeEmpty.css +10 -0
  66. package/src/components/shared/containers/SCloudChromeEmpty.vue +18 -0
  67. package/src/components/shared/containers/SCloudChromeRow.css +78 -0
  68. package/src/components/shared/containers/SCloudChromeRow.vue +108 -0
  69. package/src/components/shared/containers/SCollapsiblePanel.css +57 -0
  70. package/src/components/shared/containers/SCollapsiblePanel.vue +360 -0
  71. package/src/components/shared/containers/SDrawer.css +60 -0
  72. package/src/components/shared/containers/SDrawer.vue +86 -0
  73. package/src/components/shared/containers/SFormGrid.css +26 -0
  74. package/src/components/shared/containers/SFormGrid.vue +46 -0
  75. package/src/components/shared/containers/SFormGridItem.css +18 -0
  76. package/src/components/shared/containers/SFormGridItem.vue +29 -0
  77. package/src/components/shared/containers/SLeftSidebar.vue +74 -0
  78. package/src/components/shared/containers/SModal.css +27 -0
  79. package/src/components/shared/containers/SModal.vue +105 -0
  80. package/src/components/shared/containers/SPageHeader.css +66 -0
  81. package/src/components/shared/containers/SPageHeader.vue +45 -0
  82. package/src/components/shared/containers/SPanel.css +134 -0
  83. package/src/components/shared/containers/SPanel.vue +169 -0
  84. package/src/components/shared/containers/SPopover.css +35 -0
  85. package/src/components/shared/containers/SPopover.vue +551 -0
  86. package/src/components/shared/containers/SSidebarGroup.css +12 -0
  87. package/src/components/shared/containers/SSidebarGroup.vue +443 -0
  88. package/src/components/shared/containers/SSidebarSection.css +41 -0
  89. package/src/components/shared/containers/SSidebarSection.vue +268 -0
  90. package/src/components/shared/containers/disclosureSummary.ts +48 -0
  91. package/src/components/shared/containers/popover.ts +72 -0
  92. package/src/components/shared/containers/sidebar.ts +206 -0
  93. package/src/components/shared/controls/SButton.css +274 -0
  94. package/src/components/shared/controls/SButton.vue +211 -0
  95. package/src/components/shared/controls/SCheckbox.css +65 -0
  96. package/src/components/shared/controls/SCheckbox.vue +130 -0
  97. package/src/components/shared/controls/SCheckboxGroup.vue +134 -0
  98. package/src/components/shared/controls/SChoiceCards.css +29 -0
  99. package/src/components/shared/controls/SChoiceCards.vue +145 -0
  100. package/src/components/shared/controls/SColorSelect.vue +146 -0
  101. package/src/components/shared/controls/SComboboxTrigger.css +35 -0
  102. package/src/components/shared/controls/SComboboxTrigger.vue +122 -0
  103. package/src/components/shared/controls/SContextMenu.css +11 -0
  104. package/src/components/shared/controls/SContextMenu.vue +186 -0
  105. package/src/components/shared/controls/SContextToggleButton.vue +64 -0
  106. package/src/components/shared/controls/SDragHandle.vue +100 -0
  107. package/src/components/shared/controls/SDropdownMenu.css +5 -0
  108. package/src/components/shared/controls/SDropdownMenu.vue +188 -0
  109. package/src/components/shared/controls/SField.css +79 -0
  110. package/src/components/shared/controls/SField.vue +89 -0
  111. package/src/components/shared/controls/SFieldGroup.css +58 -0
  112. package/src/components/shared/controls/SFieldGroup.vue +117 -0
  113. package/src/components/shared/controls/SFieldLabel.css +12 -0
  114. package/src/components/shared/controls/SFieldLabel.vue +51 -0
  115. package/src/components/shared/controls/SInlineTokenEditor.css +61 -0
  116. package/src/components/shared/controls/SInlineTokenEditor.vue +1081 -0
  117. package/src/components/shared/controls/SInputText.css +66 -0
  118. package/src/components/shared/controls/SInputText.vue +321 -0
  119. package/src/components/shared/controls/SInteractiveSurface.css +72 -0
  120. package/src/components/shared/controls/SInteractiveSurface.vue +326 -0
  121. package/src/components/shared/controls/SLink.css +114 -0
  122. package/src/components/shared/controls/SLink.ts +85 -0
  123. package/src/components/shared/controls/SLink.vue +114 -0
  124. package/src/components/shared/controls/SListbox.css +31 -0
  125. package/src/components/shared/controls/SListbox.vue +388 -0
  126. package/src/components/shared/controls/SListboxOption.css +63 -0
  127. package/src/components/shared/controls/SListboxOption.vue +85 -0
  128. package/src/components/shared/controls/SMarker.css +19 -0
  129. package/src/components/shared/controls/SMarker.vue +33 -0
  130. package/src/components/shared/controls/SMenuSurface.css +20 -0
  131. package/src/components/shared/controls/SMenuSurface.vue +333 -0
  132. package/src/components/shared/controls/SSegmentedControl.css +38 -0
  133. package/src/components/shared/controls/SSegmentedControl.vue +134 -0
  134. package/src/components/shared/controls/SSelect.css +12 -0
  135. package/src/components/shared/controls/SSelect.vue +338 -0
  136. package/src/components/shared/controls/STextarea.css +31 -0
  137. package/src/components/shared/controls/STextarea.vue +316 -0
  138. package/src/components/shared/controls/_internal/SInlineTokenSurface.vue +224 -0
  139. package/src/components/shared/controls/menu.ts +178 -0
  140. package/src/components/shared/data-display/SActionCard.css +6 -0
  141. package/src/components/shared/data-display/SActionCard.vue +49 -0
  142. package/src/components/shared/data-display/SActionList.css +15 -0
  143. package/src/components/shared/data-display/SActionList.vue +34 -0
  144. package/src/components/shared/data-display/SActionListItem.css +114 -0
  145. package/src/components/shared/data-display/SActionListItem.vue +230 -0
  146. package/src/components/shared/data-display/SBadge.css +73 -0
  147. package/src/components/shared/data-display/SBadge.vue +52 -0
  148. package/src/components/shared/data-display/SCodeBlock.css +56 -0
  149. package/src/components/shared/data-display/SCodeBlock.vue +188 -0
  150. package/src/components/shared/data-display/SCodeEditor.css +178 -0
  151. package/src/components/shared/data-display/SCodeEditor.vue +274 -0
  152. package/src/components/shared/data-display/SCodeSearchPanel.vue +327 -0
  153. package/src/components/shared/data-display/SCopyField.vue +77 -0
  154. package/src/components/shared/data-display/SDocBlock.css +136 -0
  155. package/src/components/shared/data-display/SDocBlock.vue +358 -0
  156. package/src/components/shared/data-display/SEmptyState.css +42 -0
  157. package/src/components/shared/data-display/SEmptyState.vue +79 -0
  158. package/src/components/shared/data-display/SExpandableText.vue +314 -0
  159. package/src/components/shared/data-display/SJsonTree.css +6 -0
  160. package/src/components/shared/data-display/SJsonTree.vue +111 -0
  161. package/src/components/shared/data-display/SKeyValueGrid.css +37 -0
  162. package/src/components/shared/data-display/SKeyValueGrid.vue +277 -0
  163. package/src/components/shared/data-display/SLinkedSystemsList.vue +270 -0
  164. package/src/components/shared/data-display/SMessage.css +88 -0
  165. package/src/components/shared/data-display/SMessage.vue +152 -0
  166. package/src/components/shared/data-display/SMetricCard.css +32 -0
  167. package/src/components/shared/data-display/SMetricCard.vue +117 -0
  168. package/src/components/shared/data-display/SProgressBar.css +84 -0
  169. package/src/components/shared/data-display/SProgressBar.vue +114 -0
  170. package/src/components/shared/data-display/SProgressIndicator.css +59 -0
  171. package/src/components/shared/data-display/SProgressIndicator.vue +104 -0
  172. package/src/components/shared/data-display/SSectionHeader.css +84 -0
  173. package/src/components/shared/data-display/SSectionHeader.vue +58 -0
  174. package/src/components/shared/data-display/SStatus.css +90 -0
  175. package/src/components/shared/data-display/SStatus.vue +79 -0
  176. package/src/components/shared/data-display/STable.css +83 -0
  177. package/src/components/shared/data-display/STable.vue +339 -0
  178. package/src/components/shared/data-display/SToastContainer.css +53 -0
  179. package/src/components/shared/data-display/SToastContainer.vue +31 -0
  180. package/src/components/shared/data-display/STooltip.css +114 -0
  181. package/src/components/shared/data-display/STooltip.vue +550 -0
  182. package/src/components/shared/data-display/STooltipAnchor.css +21 -0
  183. package/src/components/shared/data-display/STooltipAnchor.vue +119 -0
  184. package/src/components/shared/data-display/SVirtualList.css +24 -0
  185. package/src/components/shared/data-display/SVirtualList.ts +19 -0
  186. package/src/components/shared/data-display/SVirtualList.vue +361 -0
  187. package/src/components/shared/data-display/json.ts +136 -0
  188. package/src/components/shared/data-display/table.ts +76 -0
  189. package/src/components/shared/database/SDataGrid.css +3 -0
  190. package/src/components/shared/database/SDataGrid.vue +464 -0
  191. package/src/components/shared/database/SSqlEditor.vue +363 -0
  192. package/src/components/shared/database/dataGrid.ts +61 -0
  193. package/src/components/shared/feedback/SAsyncState.css +26 -0
  194. package/src/components/shared/feedback/SAsyncState.vue +190 -0
  195. package/src/components/shared/graph/SGraphViewport.ts +1 -0
  196. package/src/components/shared/graph/SGraphViewport.vue +858 -0
  197. package/src/components/shared/graph/_internal/graphViewportContract.ts +362 -0
  198. package/src/components/shared/markdown.ts +169 -0
  199. package/src/components/shared/navigation/SBottomNav.vue +692 -0
  200. package/src/components/shared/navigation/SBreadcrumbs.css +26 -0
  201. package/src/components/shared/navigation/SBreadcrumbs.vue +155 -0
  202. package/src/components/shared/navigation/SCatalogNavigator.css +100 -0
  203. package/src/components/shared/navigation/SCatalogNavigator.vue +308 -0
  204. package/src/components/shared/navigation/STab.vue +363 -0
  205. package/src/components/shared/navigation/STabList.vue +442 -0
  206. package/src/components/shared/navigation/STabPanel.vue +165 -0
  207. package/src/components/shared/navigation/STabPanels.vue +33 -0
  208. package/src/components/shared/navigation/STabs.vue +617 -0
  209. package/src/components/shared/navigation/SWizardSteps.css +69 -0
  210. package/src/components/shared/navigation/SWizardSteps.vue +171 -0
  211. package/src/components/shared/navigation/_internal/SCatalogNavigatorGroup.vue +63 -0
  212. package/src/components/shared/persona/SPersonaProjectPicker.css +36 -0
  213. package/src/components/shared/persona/SPersonaProjectPicker.vue +361 -0
  214. package/src/components/shared/persona/SPersonaRunWorkbench.css +109 -0
  215. package/src/components/shared/persona/SPersonaRunWorkbench.vue +549 -0
  216. package/src/components/shared/persona/_internal/SPersonaRunConsole.css +40 -0
  217. package/src/components/shared/persona/_internal/SPersonaRunConsole.vue +298 -0
  218. package/src/components/shared/persona/_internal/inventory.ts +110 -0
  219. package/src/components/shared/persona/_internal/labels.ts +79 -0
  220. package/src/components/shared/persona/presentation.ts +371 -0
  221. package/src/components/shared/persona/types.ts +256 -0
  222. package/src/composables/uiPreferencesReset.ts +52 -0
  223. package/src/composables/useClipboard.ts +37 -0
  224. package/src/composables/useDialogOverlay.ts +85 -0
  225. package/src/composables/useDisclosure.ts +40 -0
  226. package/src/composables/useFloatingPosition.ts +245 -0
  227. package/src/composables/useNotifier.ts +26 -0
  228. package/src/composables/useSidebarPanelState.ts +610 -0
  229. package/src/internal/codeEditorContract.ts +110 -0
  230. package/src/internal/codeEditorExtensions.ts +27 -0
  231. package/src/internal/feedbackPresentation.ts +136 -0
  232. package/src/internal/fieldSurface.ts +59 -0
  233. package/src/internal/focusNavigation.ts +69 -0
  234. package/src/internal/inlineTokenEditorContract.ts +136 -0
  235. package/src/internal/interactiveElement.ts +33 -0
  236. package/src/internal/layerStack.ts +202 -0
  237. package/src/internal/linkTarget.ts +256 -0
  238. package/src/internal/ownedAttrs.ts +464 -0
  239. package/src/internal/passiveContentContract.ts +129 -0
  240. package/src/internal/pointerInteractionLease.ts +81 -0
  241. package/src/internal/runtimeContract.ts +194 -0
  242. package/src/internal/runtimeTheme.ts +159 -0
  243. package/src/internal/semanticSizing.ts +60 -0
  244. package/src/internal/useMenuLayer.ts +129 -0
  245. package/src/stores/useNotifierStore.ts +69 -0
  246. package/src/styles/reference.css +4 -0
  247. package/src/styles/style.css +151 -0
  248. package/src/styles/tokens.css +836 -0
  249. package/src/theme.ts +449 -0
  250. package/src/types/highlight-js-lib.d.ts +76 -0
  251. package/src/variants.ts +15 -0
@@ -0,0 +1,1081 @@
1
+ <template>
2
+ <SField
3
+ :control-id="editorId"
4
+ :label="label"
5
+ :required="validatedRequired"
6
+ :help-text="helpText"
7
+ :error-message="errorMessage"
8
+ :invalid="validatedInvalid"
9
+ :disabled="validatedDisabled"
10
+ :readonly="validatedReadonly"
11
+ :fill="validatedAppearance === 'ghost' ? 'container' : 'content'"
12
+ >
13
+ <template #default="field">
14
+ <SInlineTokenSurface
15
+ :id="editorId"
16
+ :element-ref="bindInlineEditorElement"
17
+ :owned-attributes="ownedAttrs.bindings()"
18
+ :field-bindings="resolveFieldBindings(field)"
19
+ :segments="inlineSegments"
20
+ :trailing-caret-helper="hasTrailingInlineCaretHelper"
21
+ :open-disabled="validatedDisabled"
22
+ :remove-disabled="validatedDisabled || validatedReadonly"
23
+ :appearance="validatedAppearance"
24
+ :invalid="field.invalid"
25
+ :disabled="field.disabled"
26
+ :readonly="field.readonly"
27
+ :required="field.required"
28
+ :rows="rows"
29
+ :max-rows="maxRows"
30
+ :placeholder="placeholder"
31
+ :placeholder-visible="isInlinePlaceholderVisible"
32
+ :labelled-by="mergeIdReferences(ownedAttrs.string('aria-labelledby'), field.labelledBy)"
33
+ :described-by="mergeIdReferences(ownedAttrs.string('aria-describedby'), field.describedBy)"
34
+ @beforeinput="handleInlineBeforeInput"
35
+ @input="handleInlineInput"
36
+ @keydown="handleInlineKeydown"
37
+ @keyup="handleInlineKeyup"
38
+ @click="handleInlineClick"
39
+ @paste="handleInlinePaste"
40
+ @token-action="handleInlineTokenAction"
41
+ />
42
+ </template>
43
+ </SField>
44
+ </template>
45
+
46
+ <script setup lang="ts">
47
+ import { computed, ref, useId, watch } from 'vue'
48
+ import { mergeIdReferences, useOwnedAttrs } from '../../../internal/ownedAttrs'
49
+ import { resolveFieldSurfaceBindings, type FieldSurfaceBindings } from '../../../internal/fieldSurface'
50
+ import {
51
+ composeInlineTokenSegments,
52
+ type InlineTokenEditorSegmentContract,
53
+ inlineTokenSegmentsToText,
54
+ parseInlineTokenEditorSource,
55
+ validateInlineTokenCursorBoundary,
56
+ } from '../../../internal/inlineTokenEditorContract'
57
+ import {
58
+ validateBoolean,
59
+ validateBoundedInteger,
60
+ validateDomId,
61
+ validateExactString,
62
+ validateNonEmptyString,
63
+ } from '../../../internal/runtimeContract'
64
+ import SInlineTokenSurface, {
65
+ INLINE_TOKEN_EDITOR_VISUAL_CARET_TEXT,
66
+ type InlineTokenActionEvent,
67
+ type InlineTokenElementRef,
68
+ } from './_internal/SInlineTokenSurface.vue'
69
+ import SField from './SField.vue'
70
+
71
+ defineOptions({
72
+ inheritAttrs: false,
73
+ })
74
+
75
+ /** Canonical text/token segment без nullable/fallback states. / Canonical text/token segment without nullable or fallback states. */
76
+ export type SInlineTokenEditorSegment = InlineTokenEditorSegmentContract
77
+
78
+ /** Public editor contract с exact model mirror и semantic row sizing. / Public editor contract with an exact model mirror and semantic row sizing. */
79
+ export interface Props {
80
+ modelValue: string
81
+ segments: readonly SInlineTokenEditorSegment[]
82
+ label?: string
83
+ placeholder?: string
84
+ disabled?: boolean
85
+ readonly?: boolean
86
+ required?: boolean
87
+ invalid?: boolean
88
+ errorMessage?: string
89
+ helpText?: string
90
+ id?: string
91
+ rows?: number
92
+ maxRows?: number
93
+ appearance?: 'default' | 'ghost'
94
+ }
95
+
96
+ /** Exact text/cursor state after a user or imperative edit. / Exact text/cursor state после user или imperative edit. */
97
+ export interface SInlineTokenEditorState {
98
+ text: string
99
+ cursor: number
100
+ }
101
+
102
+ /** Exact identity of one token occurrence and its domain entity. / Точная identity одного вхождения токена и его доменной сущности. */
103
+ export interface SInlineTokenEditorTokenReference {
104
+ occurrenceId: string
105
+ id: string
106
+ }
107
+
108
+ /** Atomic controlled replacement request with one cursor commit. / Атомарный запрос controlled-замены с единым commit курсора. */
109
+ export interface SInlineTokenEditorReplacement {
110
+ start: number
111
+ end: number
112
+ segments: readonly SInlineTokenEditorSegment[]
113
+ cursor: number
114
+ }
115
+
116
+ /** Public imperative surface for focus, selection, and atomic domain insertions. / Публичная imperative-поверхность focus, selection и атомарных доменных вставок. */
117
+ export interface SInlineTokenEditorApi {
118
+ focus: () => void
119
+ insertSoftLineBreak: () => void
120
+ getSelectionOffset: () => number
121
+ queueSelectionOffset: (offset: number) => void
122
+ setSelectionOffset: (offset: number) => void
123
+ replaceRange: (replacement: SInlineTokenEditorReplacement) => void
124
+ }
125
+
126
+ const props = withDefaults(defineProps<Props>(), {
127
+ label: undefined,
128
+ placeholder: undefined,
129
+ disabled: false,
130
+ readonly: false,
131
+ required: false,
132
+ invalid: false,
133
+ errorMessage: undefined,
134
+ helpText: undefined,
135
+ id: undefined,
136
+ rows: 3,
137
+ maxRows: 0,
138
+ appearance: 'default',
139
+ })
140
+
141
+ const emit = defineEmits<{
142
+ (e: 'update:modelValue', value: string): void
143
+ (e: 'update:segments', value: SInlineTokenEditorSegment[]): void
144
+ (e: 'keydown', event: KeyboardEvent): void
145
+ (e: 'keyup', event: KeyboardEvent): void
146
+ (e: 'click', event: MouseEvent): void
147
+ (e: 'open-token', token: SInlineTokenEditorTokenReference): void
148
+ (e: 'remove-token', token: SInlineTokenEditorTokenReference): void
149
+ (e: 'state-change', state: SInlineTokenEditorState): void
150
+ }>()
151
+ const ownedAttrs = useOwnedAttrs({ component: 'SInlineTokenEditor', owner: 'inline token editing surface' })
152
+
153
+ const generatedEditorId = useId()
154
+ const editorId = computed(() => props.id === undefined
155
+ ? `s-inline-token-editor-${generatedEditorId}`
156
+ : validateDomId('SInlineTokenEditor', 'id', props.id))
157
+
158
+ const inlineEditorRef = ref<HTMLDivElement | null>(null)
159
+ const bindInlineEditorElement: InlineTokenElementRef = (target) => {
160
+ if (target !== null && !(target instanceof HTMLDivElement)) {
161
+ throw new TypeError('SInlineTokenEditor: editing surface ref должен быть HTMLDivElement. / SInlineTokenEditor: editing surface ref must be an HTMLDivElement.')
162
+ }
163
+ inlineEditorRef.value = target
164
+ }
165
+ const lastSelectionOffset = ref(0)
166
+ const pendingInlineSelectionOffset = ref<number | null>(null)
167
+ const inlineSelectionOffsetToRestore = ref<number | null>(null)
168
+ const INLINE_VISUAL_CARET_TEXT = INLINE_TOKEN_EDITOR_VISUAL_CARET_TEXT
169
+
170
+ const inlineSegments = computed<readonly SInlineTokenEditorSegment[]>(() =>
171
+ parseInlineTokenEditorSource(props.modelValue, props.segments))
172
+ const validatedDisabled = computed(() => validateBoolean('SInlineTokenEditor', 'disabled', props.disabled))
173
+ const validatedReadonly = computed(() => validateBoolean('SInlineTokenEditor', 'readonly', props.readonly))
174
+ const validatedRequired = computed(() => validateBoolean('SInlineTokenEditor', 'required', props.required))
175
+ const validatedInvalid = computed(() => validateBoolean('SInlineTokenEditor', 'invalid', props.invalid))
176
+ const validatedAppearance = computed(() => validateExactString(
177
+ 'SInlineTokenEditor', 'appearance', props.appearance, ['default', 'ghost'] as const,
178
+ ))
179
+
180
+ const hasSemanticInlineContent = computed(() => inlineSegments.value.length > 0)
181
+ const isInlinePlaceholderVisible = computed(() => !hasSemanticInlineContent.value)
182
+ const inlineTrailingCaretHelperKind = computed<'newline' | 'token' | null>(() => {
183
+ const inlineText = segmentsToText(inlineSegments.value)
184
+ if (inlineText.endsWith('\n')) {
185
+ return 'newline'
186
+ }
187
+
188
+ const lastSegment = inlineSegments.value[inlineSegments.value.length - 1]
189
+ if (lastSegment?.kind === 'token') {
190
+ return 'token'
191
+ }
192
+
193
+ return null
194
+ })
195
+ const hasTrailingInlineCaretHelper = computed(() => inlineTrailingCaretHelperKind.value !== null)
196
+ const inlineDomSignature = computed(() => JSON.stringify({
197
+ segments: inlineSegments.value,
198
+ trailingCaretHelperKind: inlineTrailingCaretHelperKind.value,
199
+ tokenRemovalDisabled: validatedDisabled.value || validatedReadonly.value,
200
+ }))
201
+
202
+
203
+ function resolveFieldBindings(field: {
204
+ invalid: boolean
205
+ disabled: boolean
206
+ readonly: boolean
207
+ }): FieldSurfaceBindings {
208
+ return resolveFieldSurfaceBindings('SInlineTokenEditor', {
209
+ kind: 'inline-token-editor',
210
+ appearance: validatedAppearance.value === 'default' ? 'field' : 'ghost',
211
+ invalid: field.invalid,
212
+ disabled: field.disabled,
213
+ readonly: field.readonly,
214
+ })
215
+ }
216
+
217
+ const segmentsToText = (segments: readonly SInlineTokenEditorSegment[]): string =>
218
+ inlineTokenSegmentsToText(segments)
219
+
220
+ const isInlineVisualCaretTextNode = (node: Node): node is Text =>
221
+ node.nodeType === Node.TEXT_NODE && node.textContent === INLINE_VISUAL_CARET_TEXT
222
+
223
+ const getInlineTokenLogicalLength = (node: HTMLElement): number =>
224
+ validateNonEmptyString('SInlineTokenEditor', 'DOM token label', node.dataset.segmentLabel).length
225
+
226
+ const findLastInlineTextNode = (
227
+ nodes: Iterable<Node>,
228
+ predicate: (node: Node) => node is Text,
229
+ ): Text | null => {
230
+ const entries = Array.from(nodes)
231
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
232
+ const candidate = entries[index]
233
+ if (candidate && predicate(candidate)) {
234
+ return candidate
235
+ }
236
+ }
237
+ return null
238
+ }
239
+
240
+ const findFirstEditableInlineTextNode = (node: Node | null): Text | null => {
241
+ if (!node) {
242
+ return null
243
+ }
244
+ if (node.nodeType === Node.TEXT_NODE) {
245
+ const textNode = node as Text
246
+ return isInlineVisualCaretTextNode(textNode) ? null : textNode
247
+ }
248
+
249
+ for (const child of Array.from(node.childNodes)) {
250
+ const found = findFirstEditableInlineTextNode(child)
251
+ if (found) {
252
+ return found
253
+ }
254
+ }
255
+
256
+ return null
257
+ }
258
+
259
+ const getNodeLogicalLength = (node: Node): number => {
260
+ if (node.nodeType === Node.TEXT_NODE) {
261
+ if (isInlineVisualCaretTextNode(node)) {
262
+ return 0
263
+ }
264
+ return (node as Text).data.length
265
+ }
266
+ if (node instanceof HTMLElement && node.dataset.segmentKind === 'token') {
267
+ return getInlineTokenLogicalLength(node)
268
+ }
269
+ let total = 0
270
+ node.childNodes.forEach((child) => {
271
+ total += getNodeLogicalLength(child)
272
+ })
273
+ return total
274
+ }
275
+
276
+ const validateDomBoundaryOffset = (node: Node, offset: number, coordinate: string): number => {
277
+ if (!Number.isInteger(offset)) {
278
+ throw new TypeError(`SInlineTokenEditor: ${coordinate} должен быть целым DOM offset. / SInlineTokenEditor: ${coordinate} must be an integer DOM offset.`)
279
+ }
280
+ const maximum = node.nodeType === Node.TEXT_NODE
281
+ ? (node as Text).data.length
282
+ : node.childNodes.length
283
+ if (offset < 0 || offset > maximum) {
284
+ throw new RangeError(`SInlineTokenEditor: ${coordinate}=${offset} вне DOM range [0, ${maximum}]. / SInlineTokenEditor: ${coordinate}=${offset} is outside DOM range [0, ${maximum}].`)
285
+ }
286
+ return offset
287
+ }
288
+
289
+ const calculateOffsetWithin = (root: Node, target: Node, targetOffset: number): number => {
290
+ if (root === target) {
291
+ const validatedTargetOffset = validateDomBoundaryOffset(target, targetOffset, 'selection boundary')
292
+ if (root.nodeType === Node.TEXT_NODE) {
293
+ if (isInlineVisualCaretTextNode(root)) {
294
+ return 0
295
+ }
296
+ return validatedTargetOffset
297
+ }
298
+ let subtotal = 0
299
+ const children = Array.from(root.childNodes)
300
+ for (let index = 0; index < validatedTargetOffset; index += 1) {
301
+ subtotal += getNodeLogicalLength(children[index]!)
302
+ }
303
+ return subtotal
304
+ }
305
+
306
+ if (root.nodeType === Node.TEXT_NODE) {
307
+ if (isInlineVisualCaretTextNode(root)) {
308
+ return 0
309
+ }
310
+ return (root as Text).data.length
311
+ }
312
+
313
+ if (root instanceof HTMLElement && root.dataset.segmentKind === 'token') {
314
+ return getInlineTokenLogicalLength(root)
315
+ }
316
+
317
+ let total = 0
318
+ for (const child of Array.from(root.childNodes)) {
319
+ if (child === target || child.contains(target)) {
320
+ total += calculateOffsetWithin(child, target, targetOffset)
321
+ return total
322
+ }
323
+ total += getNodeLogicalLength(child)
324
+ }
325
+ throw new RangeError('SInlineTokenEditor: selection boundary не принадлежит editing surface. / SInlineTokenEditor: selection boundary does not belong to the editing surface.')
326
+ }
327
+
328
+ const getActiveInlineSelectionOffset = (
329
+ source: readonly SInlineTokenEditorSegment[],
330
+ coordinate: string,
331
+ ): number => {
332
+ const editor = inlineEditorRef.value
333
+ if (!editor) {
334
+ throw new Error('SInlineTokenEditor: editing surface не готова для getSelectionOffset. / SInlineTokenEditor: editing surface is not ready for getSelectionOffset.')
335
+ }
336
+ const selection = window.getSelection()
337
+ if (!selection) {
338
+ throw new Error('SInlineTokenEditor: Selection API недоступен для getSelectionOffset. / SInlineTokenEditor: Selection API is unavailable for getSelectionOffset.')
339
+ }
340
+ if (selection.rangeCount === 0) {
341
+ throw new Error('SInlineTokenEditor: активная selection отсутствует для getSelectionOffset. / SInlineTokenEditor: no active selection exists for getSelectionOffset.')
342
+ }
343
+ const range = selection.getRangeAt(0)
344
+ if (!editor.contains(range.startContainer) && range.startContainer !== editor) {
345
+ throw new Error('SInlineTokenEditor: активная selection не принадлежит editing surface. / SInlineTokenEditor: the active selection does not belong to the editing surface.')
346
+ }
347
+ if (range.startContainer === editor && editor.childNodes.length === 0) {
348
+ validateDomBoundaryOffset(editor, range.startOffset, 'empty editor selection boundary')
349
+ lastSelectionOffset.value = 0
350
+ return 0
351
+ }
352
+ const offset = validateInlineTokenCursorBoundary(
353
+ source,
354
+ calculateOffsetWithin(editor, range.startContainer, range.startOffset),
355
+ coordinate,
356
+ )
357
+ lastSelectionOffset.value = offset
358
+ return offset
359
+ }
360
+
361
+ const getInlineSelectionOffset = (): number => getActiveInlineSelectionOffset(
362
+ inlineSegments.value,
363
+ 'active selection offset',
364
+ )
365
+
366
+ const buildInlineRangeAtOffset = (editor: HTMLDivElement, offset: number): Range => {
367
+ const range = document.createRange()
368
+ const source = inlineSegments.value
369
+ const validatedOffset = validateInlineTokenCursorBoundary(source, offset, 'selection offset')
370
+ let remaining = validatedOffset
371
+ const inlineText = segmentsToText(source)
372
+ if (hasTrailingInlineCaretHelper.value && validatedOffset === inlineText.length) {
373
+ const visualCaretNode = findLastInlineTextNode(editor.childNodes, isInlineVisualCaretTextNode)
374
+ if (visualCaretNode) {
375
+ range.setStart(visualCaretNode, visualCaretNode.data.length)
376
+ range.collapse(true)
377
+ return range
378
+ }
379
+ }
380
+
381
+ const placeWithin = (node: Node): boolean => {
382
+ if (node.nodeType === Node.TEXT_NODE) {
383
+ if (isInlineVisualCaretTextNode(node)) {
384
+ range.setStart(node, (node as Text).data.length)
385
+ range.collapse(true)
386
+ return true
387
+ }
388
+ const length = (node as Text).data.length
389
+ if (remaining > length) {
390
+ throw new RangeError(`SInlineTokenEditor: selection traversal overflow ${remaining} > ${length}.`)
391
+ }
392
+ range.setStart(node, remaining)
393
+ range.collapse(true)
394
+ return true
395
+ }
396
+
397
+ if (node instanceof HTMLElement && node.dataset.segmentKind === 'token') {
398
+ const tokenLength = getInlineTokenLogicalLength(node)
399
+ const parent = node.parentNode
400
+ if (!parent) return false
401
+ const children = Array.from(parent.childNodes)
402
+ const nodeIndex = children.indexOf(node)
403
+ if (remaining === 0) {
404
+ range.setStart(parent, nodeIndex)
405
+ range.collapse(true)
406
+ return true
407
+ }
408
+ if (remaining === tokenLength) {
409
+ const nextTextNode = findFirstEditableInlineTextNode(children[nodeIndex + 1] ?? null)
410
+ if (nextTextNode) {
411
+ range.setStart(nextTextNode, 0)
412
+ range.collapse(true)
413
+ return true
414
+ }
415
+
416
+ const trailingCaretNode = findLastInlineTextNode(editor.childNodes, isInlineVisualCaretTextNode)
417
+ if (trailingCaretNode) {
418
+ range.setStart(trailingCaretNode, trailingCaretNode.data.length)
419
+ range.collapse(true)
420
+ return true
421
+ }
422
+
423
+ range.setStart(parent, nodeIndex + 1)
424
+ range.collapse(true)
425
+ return true
426
+ }
427
+ if (remaining < tokenLength) {
428
+ throw new RangeError(`SInlineTokenEditor: selection offset ${remaining} попал внутрь atomic token ${JSON.stringify(node.dataset.segmentId)}. / SInlineTokenEditor: selection offset ${remaining} fell inside atomic token ${JSON.stringify(node.dataset.segmentId)}.`)
429
+ }
430
+ remaining -= tokenLength
431
+ return false
432
+ }
433
+
434
+ const children = Array.from(node.childNodes)
435
+ if (!children.length) {
436
+ range.setStart(node, 0)
437
+ range.collapse(true)
438
+ return true
439
+ }
440
+
441
+ for (const child of children) {
442
+ const childLength = getNodeLogicalLength(child)
443
+ if (remaining <= childLength) {
444
+ return placeWithin(child)
445
+ }
446
+ remaining -= childLength
447
+ }
448
+
449
+ range.setStart(node, children.length)
450
+ range.collapse(true)
451
+ return true
452
+ }
453
+
454
+ placeWithin(editor)
455
+ return range
456
+ }
457
+
458
+ const placeInlineSelectionAtOffset = (offset: number): void => {
459
+ const editor = inlineEditorRef.value
460
+ if (!editor) {
461
+ throw new Error('SInlineTokenEditor: editing surface не готова. / SInlineTokenEditor: editing surface is not ready.')
462
+ }
463
+
464
+ const selection = window.getSelection()
465
+ if (!selection) {
466
+ throw new Error('SInlineTokenEditor: Selection API недоступен. / SInlineTokenEditor: Selection API is unavailable.')
467
+ }
468
+
469
+ const range = buildInlineRangeAtOffset(editor, offset)
470
+ selection.removeAllRanges()
471
+ selection.addRange(range)
472
+ lastSelectionOffset.value = validateInlineTokenCursorBoundary(inlineSegments.value, offset, 'placed selection offset')
473
+ }
474
+
475
+ const emitInlineEditorState = (textOverride?: string): void => {
476
+ emit('state-change', {
477
+ text: textOverride ?? segmentsToText(inlineSegments.value),
478
+ cursor: getInlineSelectionOffset(),
479
+ })
480
+ }
481
+
482
+ const readInlineSegmentsFromDom = (): SInlineTokenEditorSegment[] => {
483
+ const editor = inlineEditorRef.value
484
+ if (!editor) {
485
+ throw new Error('SInlineTokenEditor: невозможно прочитать DOM до readiness. / SInlineTokenEditor: cannot read DOM before readiness.')
486
+ }
487
+ if (
488
+ editor.childNodes.length === 1
489
+ && editor.firstChild instanceof HTMLBRElement
490
+ ) {
491
+ return []
492
+ }
493
+
494
+ const segments: SInlineTokenEditorSegment[] = []
495
+ const appendText = (text: string) => {
496
+ if (!text) return
497
+ const previous = segments[segments.length - 1]
498
+ if (previous?.kind === 'text') {
499
+ segments[segments.length - 1] = { kind: 'text', text: previous.text + text }
500
+ } else {
501
+ segments.push({ kind: 'text', text })
502
+ }
503
+ }
504
+
505
+ const visit = (node: Node): void => {
506
+ if (node.nodeType === Node.TEXT_NODE) {
507
+ if (isInlineVisualCaretTextNode(node)) {
508
+ return
509
+ }
510
+ appendText((node as Text).data)
511
+ return
512
+ }
513
+
514
+ if (node instanceof HTMLElement && node.dataset.segmentKind === 'token') {
515
+ const candidate = {
516
+ kind: 'token' as const,
517
+ occurrenceId: node.dataset.segmentOccurrenceId,
518
+ id: node.dataset.segmentId,
519
+ label: node.dataset.segmentLabel,
520
+ ...(node.dataset.segmentTitle === undefined ? {} : { title: node.dataset.segmentTitle }),
521
+ }
522
+ const parsed = parseInlineTokenEditorSource(candidate.label, [candidate])
523
+ const token = parsed[0]
524
+ if (!token || token.kind !== 'token') {
525
+ throw new TypeError('SInlineTokenEditor: token DOM parser не создал token segment. / SInlineTokenEditor: token DOM parser did not produce a token segment.')
526
+ }
527
+ segments.push(token)
528
+ return
529
+ }
530
+
531
+ if (node.nodeType === Node.COMMENT_NODE) return
532
+ throw new TypeError(`SInlineTokenEditor: неожиданный DOM node ${node.nodeName} вне canonical text/token grammar. / SInlineTokenEditor: unexpected DOM node ${node.nodeName} outside the canonical text/token grammar.`)
533
+ }
534
+
535
+ editor.childNodes.forEach((node) => visit(node))
536
+ return segments
537
+ }
538
+
539
+ const emitInlineSegmentsFromDom = (): void => {
540
+ const nextSegments = readInlineSegmentsFromDom()
541
+ const nextText = segmentsToText(nextSegments)
542
+ parseInlineTokenEditorSource(nextText, nextSegments)
543
+ const nextCursor = getActiveInlineSelectionOffset(nextSegments, 'input selection offset')
544
+ lastSelectionOffset.value = nextCursor
545
+ pendingInlineSelectionOffset.value = nextCursor
546
+ emit('update:segments', nextSegments)
547
+ emit('update:modelValue', nextText)
548
+ emit('state-change', {
549
+ text: nextText,
550
+ cursor: nextCursor,
551
+ })
552
+ }
553
+
554
+ const commitInlineSegmentsUpdate = (
555
+ segments: readonly SInlineTokenEditorSegment[],
556
+ cursor: number,
557
+ ): void => {
558
+ const nextSegments = composeInlineTokenSegments(segments)
559
+ const nextText = segmentsToText(nextSegments)
560
+ parseInlineTokenEditorSource(nextText, nextSegments)
561
+ const nextCursor = validateInlineTokenCursorBoundary(nextSegments, cursor, 'committed cursor offset')
562
+ lastSelectionOffset.value = nextCursor
563
+ pendingInlineSelectionOffset.value = nextCursor
564
+ emit('update:segments', nextSegments)
565
+ emit('update:modelValue', nextText)
566
+ emit('state-change', {
567
+ text: nextText,
568
+ cursor: nextCursor,
569
+ })
570
+ }
571
+
572
+ const getTrailingInlineCaretHelperNode = (): Text | null => {
573
+ const editor = inlineEditorRef.value
574
+ if (!editor) {
575
+ return null
576
+ }
577
+ return findLastInlineTextNode(editor.childNodes, isInlineVisualCaretTextNode)
578
+ }
579
+
580
+ const isCollapsedSelectionAtTrailingInlineCaretHelper = (range: Range): boolean => {
581
+ const editor = inlineEditorRef.value
582
+ const helperNode = getTrailingInlineCaretHelperNode()
583
+ if (!editor || !helperNode || !range.collapsed || !hasTrailingInlineCaretHelper.value) {
584
+ return false
585
+ }
586
+
587
+ if (range.startContainer === helperNode) {
588
+ return true
589
+ }
590
+
591
+ if (range.startContainer !== editor) {
592
+ return false
593
+ }
594
+
595
+ const childNodes = Array.from(editor.childNodes)
596
+ const helperIndex = childNodes.indexOf(helperNode)
597
+ return helperIndex >= 0 && range.startOffset === helperIndex + 1
598
+ }
599
+
600
+ const deleteTrailingInlineLineBreak = (): void => {
601
+ const editor = inlineEditorRef.value
602
+ const helperNode = getTrailingInlineCaretHelperNode()
603
+ if (!editor || !helperNode) {
604
+ return
605
+ }
606
+
607
+ const childNodes = Array.from(editor.childNodes)
608
+ const helperIndex = childNodes.indexOf(helperNode)
609
+ const previousNode = helperIndex > 0 ? childNodes[helperIndex - 1] ?? null : null
610
+
611
+ if (!(previousNode instanceof Text) || previousNode.textContent === null || !previousNode.textContent.endsWith('\n')) {
612
+ throw new Error('Trailing inline caret helper must follow a text node ending with newline.')
613
+ }
614
+
615
+ const nextText = previousNode.textContent.slice(0, -1)
616
+ helperNode.remove()
617
+ if (nextText.length === 0) {
618
+ previousNode.remove()
619
+ } else {
620
+ previousNode.textContent = nextText
621
+ }
622
+
623
+ const selection = window.getSelection()
624
+ if (selection) {
625
+ const range = document.createRange()
626
+ range.selectNodeContents(editor)
627
+ range.collapse(false)
628
+ selection.removeAllRanges()
629
+ selection.addRange(range)
630
+ }
631
+
632
+ lastSelectionOffset.value = segmentsToText(readInlineSegmentsFromDom()).length
633
+ emitInlineSegmentsFromDom()
634
+ }
635
+
636
+ const getInlineSegmentLogicalLength = (segment: SInlineTokenEditorSegment): number =>
637
+ segment.kind === 'text' ? segment.text.length : segment.label.length
638
+
639
+ const splitInlineSegmentsAtOffset = (
640
+ segments: readonly SInlineTokenEditorSegment[],
641
+ offset: number,
642
+ ): { before: SInlineTokenEditorSegment[]; after: SInlineTokenEditorSegment[] } => {
643
+ const validatedOffset = validateInlineTokenCursorBoundary(segments, offset, 'split offset')
644
+ const before: SInlineTokenEditorSegment[] = []
645
+ const after: SInlineTokenEditorSegment[] = []
646
+ let cursor = 0
647
+ let splitDone = false
648
+
649
+ for (const segment of segments) {
650
+ const length = getInlineSegmentLogicalLength(segment)
651
+ const segmentStart = cursor
652
+ const segmentEnd = cursor + length
653
+
654
+ if (splitDone || validatedOffset <= segmentStart) {
655
+ after.push(segment)
656
+ cursor = segmentEnd
657
+ continue
658
+ }
659
+
660
+ if (validatedOffset >= segmentEnd) {
661
+ before.push(segment)
662
+ cursor = segmentEnd
663
+ continue
664
+ }
665
+
666
+ if (segment.kind === 'text') {
667
+ const splitIndex = validatedOffset - segmentStart
668
+ const left = segment.text.slice(0, splitIndex)
669
+ const right = segment.text.slice(splitIndex)
670
+ if (left) {
671
+ before.push({ kind: 'text', text: left })
672
+ }
673
+ if (right) {
674
+ after.push({ kind: 'text', text: right })
675
+ }
676
+ } else if (validatedOffset > segmentStart) {
677
+ before.push(segment)
678
+ } else {
679
+ after.push(segment)
680
+ }
681
+
682
+ splitDone = true
683
+ cursor = segmentEnd
684
+ }
685
+
686
+ return {
687
+ before: composeInlineTokenSegments(before),
688
+ after: composeInlineTokenSegments(after),
689
+ }
690
+ }
691
+
692
+ const replaceInlineSegmentsRange = (
693
+ start: number,
694
+ end: number,
695
+ replacement: readonly SInlineTokenEditorSegment[],
696
+ ): SInlineTokenEditorSegment[] => {
697
+ const source = inlineSegments.value
698
+ const validatedStart = validateInlineTokenCursorBoundary(source, start, 'replacement start')
699
+ const validatedEnd = validateInlineTokenCursorBoundary(source, end, 'replacement end')
700
+ if (validatedStart > validatedEnd) {
701
+ throw new RangeError(`SInlineTokenEditor: replacement start ${validatedStart} не может быть больше end ${validatedEnd}. / SInlineTokenEditor: replacement start ${validatedStart} cannot exceed end ${validatedEnd}.`)
702
+ }
703
+ const startSplit = splitInlineSegmentsAtOffset(source, validatedStart)
704
+ const endSplit = splitInlineSegmentsAtOffset(source, validatedEnd)
705
+ return composeInlineTokenSegments([
706
+ ...startSplit.before,
707
+ ...replacement,
708
+ ...endSplit.after,
709
+ ])
710
+ }
711
+
712
+ const resolveInlineSelectionOffsets = (range: Range): { start: number; end: number } | null => {
713
+ const editor = inlineEditorRef.value
714
+ if (!editor) {
715
+ return null
716
+ }
717
+
718
+ const inlineTextLength = segmentsToText(inlineSegments.value).length
719
+ if (isCollapsedSelectionAtTrailingInlineCaretHelper(range)) {
720
+ return { start: inlineTextLength, end: inlineTextLength }
721
+ }
722
+
723
+ const resolveBoundary = (container: Node, boundaryOffset: number): number => {
724
+ if (container === editor && editor.childNodes.length === 0) {
725
+ validateDomBoundaryOffset(container, boundaryOffset, 'empty editor selection boundary')
726
+ return 0
727
+ }
728
+ return calculateOffsetWithin(editor, container, boundaryOffset)
729
+ }
730
+
731
+ return {
732
+ start: resolveBoundary(range.startContainer, range.startOffset),
733
+ end: resolveBoundary(range.endContainer, range.endOffset),
734
+ }
735
+ }
736
+
737
+ const deleteInlineBackward = (offset: number): void => {
738
+ const sourceSegments = inlineSegments.value
739
+ const validatedOffset = validateInlineTokenCursorBoundary(sourceSegments, offset, 'delete backward offset')
740
+ if (validatedOffset === 0) {
741
+ return
742
+ }
743
+
744
+ const source = sourceSegments.map((segment): SInlineTokenEditorSegment => (
745
+ segment.kind === 'text'
746
+ ? { kind: 'text', text: segment.text }
747
+ : {
748
+ kind: 'token',
749
+ occurrenceId: segment.occurrenceId,
750
+ id: segment.id,
751
+ label: segment.label,
752
+ ...(segment.title === undefined ? {} : { title: segment.title }),
753
+ }
754
+ ))
755
+
756
+ let segmentStart = 0
757
+ for (let index = 0; index < source.length; index += 1) {
758
+ const segment = source[index]!
759
+ const segmentEnd = segmentStart + getInlineSegmentLogicalLength(segment)
760
+
761
+ if (validatedOffset > segmentEnd) {
762
+ segmentStart = segmentEnd
763
+ continue
764
+ }
765
+
766
+ if (segment.kind === 'text') {
767
+ const deleteIndex = validatedOffset - segmentStart - 1
768
+ if (deleteIndex < 0 || deleteIndex >= segment.text.length) {
769
+ throw new RangeError(`SInlineTokenEditor: delete index ${deleteIndex} вне text segment [0, ${segment.text.length - 1}]. / SInlineTokenEditor: delete index ${deleteIndex} is outside text segment [0, ${segment.text.length - 1}].`)
770
+ }
771
+ const nextText = `${segment.text.slice(0, deleteIndex)}${segment.text.slice(deleteIndex + 1)}`
772
+ source[index] = { kind: 'text', text: nextText }
773
+ commitInlineSegmentsUpdate(source, validatedOffset - 1)
774
+ return
775
+ }
776
+
777
+ const previous = source[index - 1]
778
+ const next = source[index + 1]
779
+ if (
780
+ previous?.kind === 'text'
781
+ && next?.kind === 'text'
782
+ && previous.text.endsWith(' ')
783
+ && next.text.startsWith(' ')
784
+ ) {
785
+ source[index + 1] = { kind: 'text', text: next.text.slice(1) }
786
+ }
787
+ source.splice(index, 1)
788
+ commitInlineSegmentsUpdate(source, segmentStart)
789
+ return
790
+ }
791
+ }
792
+
793
+ const ensureInlineSelectionRange = (allowSyntheticSelection: boolean): Range | null => {
794
+ const editor = inlineEditorRef.value
795
+ const selection = window.getSelection()
796
+ if (!editor || !selection) {
797
+ return null
798
+ }
799
+
800
+ if (selection.rangeCount > 0) {
801
+ const range = selection.getRangeAt(0)
802
+ if (editor.contains(range.startContainer) || range.startContainer === editor) {
803
+ return range
804
+ }
805
+ }
806
+
807
+ if (!allowSyntheticSelection) {
808
+ return null
809
+ }
810
+
811
+ editor.focus()
812
+ placeInlineSelectionAtOffset(lastSelectionOffset.value)
813
+ if (selection.rangeCount > 0) {
814
+ return selection.getRangeAt(0)
815
+ }
816
+
817
+ const range = buildInlineRangeAtOffset(editor, lastSelectionOffset.value)
818
+ selection.removeAllRanges()
819
+ selection.addRange(range)
820
+ return range
821
+ }
822
+
823
+ const insertPlainTextAtInlineSelection = (
824
+ text: string,
825
+ options: { allowSyntheticSelection?: boolean } = {},
826
+ ): void => {
827
+ if (!text || validatedDisabled.value || validatedReadonly.value) return
828
+
829
+ const range = ensureInlineSelectionRange(options.allowSyntheticSelection === true)
830
+ if (!range) {
831
+ throw new Error('SInlineTokenEditor: active selection отсутствует в editing surface. / SInlineTokenEditor: no active selection exists inside the editing surface.')
832
+ }
833
+
834
+ const offsets = resolveInlineSelectionOffsets(range)
835
+ if (!offsets) {
836
+ throw new Error('SInlineTokenEditor: selection offsets недоступны до readiness. / SInlineTokenEditor: selection offsets are unavailable before readiness.')
837
+ }
838
+
839
+ const nextSegments = replaceInlineSegmentsRange(
840
+ offsets.start,
841
+ offsets.end,
842
+ [{ kind: 'text', text }],
843
+ )
844
+ commitInlineSegmentsUpdate(nextSegments, offsets.start + text.length)
845
+ }
846
+
847
+ const insertSoftLineBreak = (): void => {
848
+ if (validatedDisabled.value || validatedReadonly.value) return
849
+ insertPlainTextAtInlineSelection('\n', { allowSyntheticSelection: true })
850
+ }
851
+
852
+ const replaceRange = (replacement: SInlineTokenEditorReplacement): void => {
853
+ if (validatedDisabled.value || validatedReadonly.value) {
854
+ throw new Error('SInlineTokenEditor: replaceRange недоступен для disabled или readonly editor. / SInlineTokenEditor: replaceRange is unavailable for a disabled or readonly editor.')
855
+ }
856
+ const editor = inlineEditorRef.value
857
+ if (!editor) {
858
+ throw new Error('SInlineTokenEditor: editing surface не готова. / SInlineTokenEditor: editing surface is not ready.')
859
+ }
860
+ const nextSegments = replaceInlineSegmentsRange(
861
+ replacement.start,
862
+ replacement.end,
863
+ replacement.segments,
864
+ )
865
+ editor.focus()
866
+ commitInlineSegmentsUpdate(nextSegments, replacement.cursor)
867
+ }
868
+
869
+ defineExpose<SInlineTokenEditorApi>({
870
+ focus: () => {
871
+ const editor = inlineEditorRef.value
872
+ if (!editor) throw new Error('SInlineTokenEditor: editing surface не готова. / SInlineTokenEditor: editing surface is not ready.')
873
+ editor.focus()
874
+ },
875
+ insertSoftLineBreak,
876
+ getSelectionOffset: getInlineSelectionOffset,
877
+ queueSelectionOffset: (offset: number) => {
878
+ pendingInlineSelectionOffset.value = validateBoundedInteger(
879
+ 'SInlineTokenEditor',
880
+ 'queued selection offset',
881
+ offset,
882
+ { minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
883
+ )
884
+ },
885
+ setSelectionOffset: (offset: number) => {
886
+ const nextOffset = validateInlineTokenCursorBoundary(inlineSegments.value, offset, 'imperative selection offset')
887
+ const editor = inlineEditorRef.value
888
+ if (!editor) throw new Error('SInlineTokenEditor: editing surface не готова. / SInlineTokenEditor: editing surface is not ready.')
889
+ if (!window.getSelection()) {
890
+ throw new Error('SInlineTokenEditor: Selection API недоступен. / SInlineTokenEditor: Selection API is unavailable.')
891
+ }
892
+ editor.focus()
893
+ placeInlineSelectionAtOffset(nextOffset)
894
+ pendingInlineSelectionOffset.value = nextOffset
895
+ emit('state-change', {
896
+ text: segmentsToText(inlineSegments.value),
897
+ cursor: nextOffset,
898
+ })
899
+ },
900
+ replaceRange,
901
+ })
902
+
903
+ function handleInlineInput() {
904
+ if (validatedDisabled.value || validatedReadonly.value) return
905
+ emitInlineSegmentsFromDom()
906
+ }
907
+
908
+ function handleInlineBeforeInput(event: InputEvent) {
909
+ if (validatedDisabled.value || validatedReadonly.value || event.defaultPrevented) return
910
+ if (event.isComposing) return
911
+
912
+ if (event.inputType === 'deleteContentBackward') {
913
+ const range = ensureInlineSelectionRange(true)
914
+ if (range && isCollapsedSelectionAtTrailingInlineCaretHelper(range)) {
915
+ if (inlineTrailingCaretHelperKind.value === 'newline') {
916
+ event.preventDefault()
917
+ deleteTrailingInlineLineBreak()
918
+ return
919
+ }
920
+ if (inlineTrailingCaretHelperKind.value === 'token') {
921
+ event.preventDefault()
922
+ deleteInlineBackward(getInlineSelectionOffset())
923
+ return
924
+ }
925
+ return
926
+ }
927
+ if (range?.collapsed) {
928
+ event.preventDefault()
929
+ deleteInlineBackward(getInlineSelectionOffset())
930
+ return
931
+ }
932
+ }
933
+
934
+ if (event.inputType === 'insertText' && typeof event.data === 'string' && event.data.length > 0) {
935
+ event.preventDefault()
936
+ insertPlainTextAtInlineSelection(event.data, { allowSyntheticSelection: true })
937
+ return
938
+ }
939
+
940
+ if (event.inputType === 'insertLineBreak' || event.inputType === 'insertParagraph') {
941
+ event.preventDefault()
942
+ insertSoftLineBreak()
943
+ }
944
+ }
945
+
946
+ function handleInlinePaste(event: ClipboardEvent) {
947
+ if (validatedDisabled.value || validatedReadonly.value) return
948
+ event.preventDefault()
949
+ if (!event.clipboardData) {
950
+ throw new TypeError('SInlineTokenEditor: paste event не содержит clipboardData. / SInlineTokenEditor: paste event has no clipboardData.')
951
+ }
952
+ const text = event.clipboardData.getData('text/plain')
953
+ insertPlainTextAtInlineSelection(text)
954
+ }
955
+
956
+ function handleInlineKeydown(event: KeyboardEvent) {
957
+ emit('keydown', event)
958
+ if (validatedDisabled.value || validatedReadonly.value || event.defaultPrevented) {
959
+ return
960
+ }
961
+ if (event.key === 'Enter' && event.shiftKey) {
962
+ event.preventDefault()
963
+ insertSoftLineBreak()
964
+ return
965
+ }
966
+
967
+ const normalizedKey = event.key === 'Space' || event.key === 'Spacebar' || event.code === 'Space'
968
+ ? ' '
969
+ : event.key
970
+
971
+ if (
972
+ !event.isComposing
973
+ && !event.ctrlKey
974
+ && !event.metaKey
975
+ && !event.altKey
976
+ && normalizedKey.length === 1
977
+ ) {
978
+ event.preventDefault()
979
+ insertPlainTextAtInlineSelection(normalizedKey, { allowSyntheticSelection: true })
980
+ }
981
+ }
982
+
983
+ function handleInlineKeyup(event: KeyboardEvent) {
984
+ emit('keyup', event)
985
+ emitInlineEditorState()
986
+ }
987
+
988
+ function handleInlineClick(event: MouseEvent) {
989
+ emit('click', event)
990
+ emitInlineEditorState()
991
+ }
992
+
993
+ function handleInlineTokenAction(event: InlineTokenActionEvent) {
994
+ const occurrenceId = validateNonEmptyString(
995
+ 'SInlineTokenEditor', 'token action occurrenceId', event.occurrenceId,
996
+ )
997
+ const tokenId = validateNonEmptyString('SInlineTokenEditor', 'token action id', event.id)
998
+ const reference: SInlineTokenEditorTokenReference = { occurrenceId, id: tokenId }
999
+ if (event.action === 'open') {
1000
+ if (!validatedDisabled.value) emit('open-token', reference)
1001
+ return
1002
+ }
1003
+ if (validatedDisabled.value || validatedReadonly.value) return
1004
+
1005
+ const source = inlineSegments.value
1006
+ let cursor = 0
1007
+ const nextSegments: SInlineTokenEditorSegment[] = []
1008
+ let removed = false
1009
+ let removeOneLeadingSeparatorSpace = false
1010
+ for (let index = 0; index < source.length; index += 1) {
1011
+ const sourceSegment = source[index]!
1012
+ const segment: SInlineTokenEditorSegment = (
1013
+ removeOneLeadingSeparatorSpace
1014
+ && sourceSegment.kind === 'text'
1015
+ && sourceSegment.text.startsWith(' ')
1016
+ )
1017
+ ? { kind: 'text', text: sourceSegment.text.slice(1) }
1018
+ : sourceSegment
1019
+ removeOneLeadingSeparatorSpace = false
1020
+ if (segment.kind === 'token' && segment.occurrenceId === occurrenceId) {
1021
+ if (segment.id !== tokenId) {
1022
+ throw new TypeError(`SInlineTokenEditor: token action id ${JSON.stringify(tokenId)} does not match occurrence ${JSON.stringify(occurrenceId)}`)
1023
+ }
1024
+ removed = true
1025
+ const previous = nextSegments[nextSegments.length - 1]
1026
+ const upcoming = source[index + 1]
1027
+ if (
1028
+ previous?.kind === 'text'
1029
+ && upcoming?.kind === 'text'
1030
+ && previous.text.endsWith(' ')
1031
+ && upcoming.text.startsWith(' ')
1032
+ ) {
1033
+ removeOneLeadingSeparatorSpace = true
1034
+ }
1035
+ continue
1036
+ }
1037
+ if (!removed) cursor += getInlineSegmentLogicalLength(segment)
1038
+ nextSegments.push(segment)
1039
+ }
1040
+ if (!removed) {
1041
+ throw new RangeError(`SInlineTokenEditor: token action occurrence ${JSON.stringify(occurrenceId)} does not exist`)
1042
+ }
1043
+ emit('remove-token', reference)
1044
+ commitInlineSegmentsUpdate(nextSegments, cursor)
1045
+ }
1046
+
1047
+ watch(
1048
+ inlineDomSignature,
1049
+ () => {
1050
+ const editor = inlineEditorRef.value
1051
+ if (!editor) return
1052
+ const queuedSelectionOffset = pendingInlineSelectionOffset.value
1053
+ const shouldRestoreSelection = queuedSelectionOffset !== null
1054
+ || document.activeElement === editor
1055
+ || editor.contains(document.activeElement)
1056
+ if (!shouldRestoreSelection) return
1057
+ inlineSelectionOffsetToRestore.value = validateInlineTokenCursorBoundary(
1058
+ inlineSegments.value,
1059
+ queuedSelectionOffset ?? getInlineSelectionOffset(),
1060
+ 'retained selection offset',
1061
+ )
1062
+ },
1063
+ { flush: 'pre' },
1064
+ )
1065
+
1066
+ watch(
1067
+ inlineDomSignature,
1068
+ () => {
1069
+ const selectionOffset = inlineSelectionOffsetToRestore.value
1070
+ if (selectionOffset === null) return
1071
+ placeInlineSelectionAtOffset(validateInlineTokenCursorBoundary(
1072
+ inlineSegments.value,
1073
+ selectionOffset,
1074
+ 'restored selection offset',
1075
+ ))
1076
+ inlineSelectionOffsetToRestore.value = null
1077
+ pendingInlineSelectionOffset.value = null
1078
+ },
1079
+ { flush: 'post' },
1080
+ )
1081
+ </script>