@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.
- package/LICENSE +21 -0
- package/README.md +157 -0
- package/docs/accessibility.md +27 -0
- package/docs/getting-started.md +80 -0
- package/docs/licensing.md +30 -0
- package/docs/public-api.md +39 -0
- package/docs/security.md +21 -0
- package/docs/theming.md +35 -0
- package/docs/versioning.md +23 -0
- package/index.ts +3 -0
- package/package.json +592 -0
- package/src/components/icons/_internal/generatedSputnigUiIconMarkup.ts +971 -0
- package/src/components/icons/sputnigUiIcons.ts +530 -0
- package/src/components/layout/SAppShell.vue +232 -0
- package/src/components/layout/SDockRegion.vue +200 -0
- package/src/components/layout/SResizeHandle.vue +251 -0
- package/src/components/layout/SSideMenuButton.vue +104 -0
- package/src/components/layout/SSideMenuRail.vue +47 -0
- package/src/components/layout/SStack.css +25 -0
- package/src/components/layout/SStack.vue +57 -0
- package/src/components/layout/SStatusBarShell.css +15 -0
- package/src/components/layout/SStatusBarShell.vue +26 -0
- package/src/components/layout/SWorkbenchLayout.vue +53 -0
- package/src/components/layout/SWorkbenchPanelShell.css +161 -0
- package/src/components/layout/SWorkbenchPanelShell.vue +286 -0
- package/src/components/layout/_internal/SAppBrand.css +45 -0
- package/src/components/layout/_internal/SAppBrand.vue +37 -0
- package/src/components/layout/_internal/SAppBrandContent.vue +30 -0
- package/src/components/layout/types.ts +28 -0
- package/src/components/shared/_internal/SDialogSurface.css +127 -0
- package/src/components/shared/_internal/SDialogSurface.vue +117 -0
- package/src/components/shared/_internal/SDisclosureSummary.css +92 -0
- package/src/components/shared/_internal/SDisclosureSummary.vue +121 -0
- package/src/components/shared/_internal/SListboxGroup.css +18 -0
- package/src/components/shared/_internal/SListboxGroup.vue +24 -0
- package/src/components/shared/_internal/SMenuItem.css +112 -0
- package/src/components/shared/_internal/SMenuItem.vue +213 -0
- package/src/components/shared/_internal/SMenuLayer.css +21 -0
- package/src/components/shared/_internal/SMenuLayer.vue +48 -0
- package/src/components/shared/_internal/SMenuSeparator.css +5 -0
- package/src/components/shared/_internal/SMenuSeparator.vue +13 -0
- package/src/components/shared/_internal/SToastItem.css +65 -0
- package/src/components/shared/_internal/SToastItem.vue +58 -0
- package/src/components/shared/_internal/STreeNode.vue +761 -0
- package/src/components/shared/_internal/dialogSurface.ts +105 -0
- package/src/components/shared/_internal/focusTrap.ts +40 -0
- package/src/components/shared/_internal/listboxContext.ts +16 -0
- package/src/components/shared/_internal/markerContract.ts +10 -0
- package/src/components/shared/_internal/menuContext.ts +23 -0
- package/src/components/shared/_internal/selectionContract.ts +239 -0
- package/src/components/shared/_internal/sidebarGroupContext.ts +31 -0
- package/src/components/shared/_internal/tabsContext.ts +57 -0
- package/src/components/shared/_internal/treeRuntime.ts +203 -0
- package/src/components/shared/_internal/useSelectionRoving.ts +162 -0
- package/src/components/shared/codeLanguages.ts +168 -0
- package/src/components/shared/complex/SKanbanCard.css +8 -0
- package/src/components/shared/complex/SKanbanCard.vue +76 -0
- package/src/components/shared/complex/SKanbanLane.vue +82 -0
- package/src/components/shared/complex/STree.css +24 -0
- package/src/components/shared/complex/STree.vue +288 -0
- package/src/components/shared/complex/treeAdapter.ts +210 -0
- package/src/components/shared/complex/types.ts +102 -0
- package/src/components/shared/containers/SCloudChrome.css +103 -0
- package/src/components/shared/containers/SCloudChrome.vue +87 -0
- package/src/components/shared/containers/SCloudChromeEmpty.css +10 -0
- package/src/components/shared/containers/SCloudChromeEmpty.vue +18 -0
- package/src/components/shared/containers/SCloudChromeRow.css +78 -0
- package/src/components/shared/containers/SCloudChromeRow.vue +108 -0
- package/src/components/shared/containers/SCollapsiblePanel.css +57 -0
- package/src/components/shared/containers/SCollapsiblePanel.vue +360 -0
- package/src/components/shared/containers/SDrawer.css +60 -0
- package/src/components/shared/containers/SDrawer.vue +86 -0
- package/src/components/shared/containers/SFormGrid.css +26 -0
- package/src/components/shared/containers/SFormGrid.vue +46 -0
- package/src/components/shared/containers/SFormGridItem.css +18 -0
- package/src/components/shared/containers/SFormGridItem.vue +29 -0
- package/src/components/shared/containers/SLeftSidebar.vue +74 -0
- package/src/components/shared/containers/SModal.css +27 -0
- package/src/components/shared/containers/SModal.vue +105 -0
- package/src/components/shared/containers/SPageHeader.css +66 -0
- package/src/components/shared/containers/SPageHeader.vue +45 -0
- package/src/components/shared/containers/SPanel.css +134 -0
- package/src/components/shared/containers/SPanel.vue +169 -0
- package/src/components/shared/containers/SPopover.css +35 -0
- package/src/components/shared/containers/SPopover.vue +551 -0
- package/src/components/shared/containers/SSidebarGroup.css +12 -0
- package/src/components/shared/containers/SSidebarGroup.vue +443 -0
- package/src/components/shared/containers/SSidebarSection.css +41 -0
- package/src/components/shared/containers/SSidebarSection.vue +268 -0
- package/src/components/shared/containers/disclosureSummary.ts +48 -0
- package/src/components/shared/containers/popover.ts +72 -0
- package/src/components/shared/containers/sidebar.ts +206 -0
- package/src/components/shared/controls/SButton.css +274 -0
- package/src/components/shared/controls/SButton.vue +211 -0
- package/src/components/shared/controls/SCheckbox.css +65 -0
- package/src/components/shared/controls/SCheckbox.vue +130 -0
- package/src/components/shared/controls/SCheckboxGroup.vue +134 -0
- package/src/components/shared/controls/SChoiceCards.css +29 -0
- package/src/components/shared/controls/SChoiceCards.vue +145 -0
- package/src/components/shared/controls/SColorSelect.vue +146 -0
- package/src/components/shared/controls/SComboboxTrigger.css +35 -0
- package/src/components/shared/controls/SComboboxTrigger.vue +122 -0
- package/src/components/shared/controls/SContextMenu.css +11 -0
- package/src/components/shared/controls/SContextMenu.vue +186 -0
- package/src/components/shared/controls/SContextToggleButton.vue +64 -0
- package/src/components/shared/controls/SDragHandle.vue +100 -0
- package/src/components/shared/controls/SDropdownMenu.css +5 -0
- package/src/components/shared/controls/SDropdownMenu.vue +188 -0
- package/src/components/shared/controls/SField.css +79 -0
- package/src/components/shared/controls/SField.vue +89 -0
- package/src/components/shared/controls/SFieldGroup.css +58 -0
- package/src/components/shared/controls/SFieldGroup.vue +117 -0
- package/src/components/shared/controls/SFieldLabel.css +12 -0
- package/src/components/shared/controls/SFieldLabel.vue +51 -0
- package/src/components/shared/controls/SInlineTokenEditor.css +61 -0
- package/src/components/shared/controls/SInlineTokenEditor.vue +1081 -0
- package/src/components/shared/controls/SInputText.css +66 -0
- package/src/components/shared/controls/SInputText.vue +321 -0
- package/src/components/shared/controls/SInteractiveSurface.css +72 -0
- package/src/components/shared/controls/SInteractiveSurface.vue +326 -0
- package/src/components/shared/controls/SLink.css +114 -0
- package/src/components/shared/controls/SLink.ts +85 -0
- package/src/components/shared/controls/SLink.vue +114 -0
- package/src/components/shared/controls/SListbox.css +31 -0
- package/src/components/shared/controls/SListbox.vue +388 -0
- package/src/components/shared/controls/SListboxOption.css +63 -0
- package/src/components/shared/controls/SListboxOption.vue +85 -0
- package/src/components/shared/controls/SMarker.css +19 -0
- package/src/components/shared/controls/SMarker.vue +33 -0
- package/src/components/shared/controls/SMenuSurface.css +20 -0
- package/src/components/shared/controls/SMenuSurface.vue +333 -0
- package/src/components/shared/controls/SSegmentedControl.css +38 -0
- package/src/components/shared/controls/SSegmentedControl.vue +134 -0
- package/src/components/shared/controls/SSelect.css +12 -0
- package/src/components/shared/controls/SSelect.vue +338 -0
- package/src/components/shared/controls/STextarea.css +31 -0
- package/src/components/shared/controls/STextarea.vue +316 -0
- package/src/components/shared/controls/_internal/SInlineTokenSurface.vue +224 -0
- package/src/components/shared/controls/menu.ts +178 -0
- package/src/components/shared/data-display/SActionCard.css +6 -0
- package/src/components/shared/data-display/SActionCard.vue +49 -0
- package/src/components/shared/data-display/SActionList.css +15 -0
- package/src/components/shared/data-display/SActionList.vue +34 -0
- package/src/components/shared/data-display/SActionListItem.css +114 -0
- package/src/components/shared/data-display/SActionListItem.vue +230 -0
- package/src/components/shared/data-display/SBadge.css +73 -0
- package/src/components/shared/data-display/SBadge.vue +52 -0
- package/src/components/shared/data-display/SCodeBlock.css +56 -0
- package/src/components/shared/data-display/SCodeBlock.vue +188 -0
- package/src/components/shared/data-display/SCodeEditor.css +178 -0
- package/src/components/shared/data-display/SCodeEditor.vue +274 -0
- package/src/components/shared/data-display/SCodeSearchPanel.vue +327 -0
- package/src/components/shared/data-display/SCopyField.vue +77 -0
- package/src/components/shared/data-display/SDocBlock.css +136 -0
- package/src/components/shared/data-display/SDocBlock.vue +358 -0
- package/src/components/shared/data-display/SEmptyState.css +42 -0
- package/src/components/shared/data-display/SEmptyState.vue +79 -0
- package/src/components/shared/data-display/SExpandableText.vue +314 -0
- package/src/components/shared/data-display/SJsonTree.css +6 -0
- package/src/components/shared/data-display/SJsonTree.vue +111 -0
- package/src/components/shared/data-display/SKeyValueGrid.css +37 -0
- package/src/components/shared/data-display/SKeyValueGrid.vue +277 -0
- package/src/components/shared/data-display/SLinkedSystemsList.vue +270 -0
- package/src/components/shared/data-display/SMessage.css +88 -0
- package/src/components/shared/data-display/SMessage.vue +152 -0
- package/src/components/shared/data-display/SMetricCard.css +32 -0
- package/src/components/shared/data-display/SMetricCard.vue +117 -0
- package/src/components/shared/data-display/SProgressBar.css +84 -0
- package/src/components/shared/data-display/SProgressBar.vue +114 -0
- package/src/components/shared/data-display/SProgressIndicator.css +59 -0
- package/src/components/shared/data-display/SProgressIndicator.vue +104 -0
- package/src/components/shared/data-display/SSectionHeader.css +84 -0
- package/src/components/shared/data-display/SSectionHeader.vue +58 -0
- package/src/components/shared/data-display/SStatus.css +90 -0
- package/src/components/shared/data-display/SStatus.vue +79 -0
- package/src/components/shared/data-display/STable.css +83 -0
- package/src/components/shared/data-display/STable.vue +339 -0
- package/src/components/shared/data-display/SToastContainer.css +53 -0
- package/src/components/shared/data-display/SToastContainer.vue +31 -0
- package/src/components/shared/data-display/STooltip.css +114 -0
- package/src/components/shared/data-display/STooltip.vue +550 -0
- package/src/components/shared/data-display/STooltipAnchor.css +21 -0
- package/src/components/shared/data-display/STooltipAnchor.vue +119 -0
- package/src/components/shared/data-display/SVirtualList.css +24 -0
- package/src/components/shared/data-display/SVirtualList.ts +19 -0
- package/src/components/shared/data-display/SVirtualList.vue +361 -0
- package/src/components/shared/data-display/json.ts +136 -0
- package/src/components/shared/data-display/table.ts +76 -0
- package/src/components/shared/database/SDataGrid.css +3 -0
- package/src/components/shared/database/SDataGrid.vue +464 -0
- package/src/components/shared/database/SSqlEditor.vue +363 -0
- package/src/components/shared/database/dataGrid.ts +61 -0
- package/src/components/shared/feedback/SAsyncState.css +26 -0
- package/src/components/shared/feedback/SAsyncState.vue +190 -0
- package/src/components/shared/graph/SGraphViewport.ts +1 -0
- package/src/components/shared/graph/SGraphViewport.vue +858 -0
- package/src/components/shared/graph/_internal/graphViewportContract.ts +362 -0
- package/src/components/shared/markdown.ts +169 -0
- package/src/components/shared/navigation/SBottomNav.vue +692 -0
- package/src/components/shared/navigation/SBreadcrumbs.css +26 -0
- package/src/components/shared/navigation/SBreadcrumbs.vue +155 -0
- package/src/components/shared/navigation/SCatalogNavigator.css +100 -0
- package/src/components/shared/navigation/SCatalogNavigator.vue +308 -0
- package/src/components/shared/navigation/STab.vue +363 -0
- package/src/components/shared/navigation/STabList.vue +442 -0
- package/src/components/shared/navigation/STabPanel.vue +165 -0
- package/src/components/shared/navigation/STabPanels.vue +33 -0
- package/src/components/shared/navigation/STabs.vue +617 -0
- package/src/components/shared/navigation/SWizardSteps.css +69 -0
- package/src/components/shared/navigation/SWizardSteps.vue +171 -0
- package/src/components/shared/navigation/_internal/SCatalogNavigatorGroup.vue +63 -0
- package/src/components/shared/persona/SPersonaProjectPicker.css +36 -0
- package/src/components/shared/persona/SPersonaProjectPicker.vue +361 -0
- package/src/components/shared/persona/SPersonaRunWorkbench.css +109 -0
- package/src/components/shared/persona/SPersonaRunWorkbench.vue +549 -0
- package/src/components/shared/persona/_internal/SPersonaRunConsole.css +40 -0
- package/src/components/shared/persona/_internal/SPersonaRunConsole.vue +298 -0
- package/src/components/shared/persona/_internal/inventory.ts +110 -0
- package/src/components/shared/persona/_internal/labels.ts +79 -0
- package/src/components/shared/persona/presentation.ts +371 -0
- package/src/components/shared/persona/types.ts +256 -0
- package/src/composables/uiPreferencesReset.ts +52 -0
- package/src/composables/useClipboard.ts +37 -0
- package/src/composables/useDialogOverlay.ts +85 -0
- package/src/composables/useDisclosure.ts +40 -0
- package/src/composables/useFloatingPosition.ts +245 -0
- package/src/composables/useNotifier.ts +26 -0
- package/src/composables/useSidebarPanelState.ts +610 -0
- package/src/internal/codeEditorContract.ts +110 -0
- package/src/internal/codeEditorExtensions.ts +27 -0
- package/src/internal/feedbackPresentation.ts +136 -0
- package/src/internal/fieldSurface.ts +59 -0
- package/src/internal/focusNavigation.ts +69 -0
- package/src/internal/inlineTokenEditorContract.ts +136 -0
- package/src/internal/interactiveElement.ts +33 -0
- package/src/internal/layerStack.ts +202 -0
- package/src/internal/linkTarget.ts +256 -0
- package/src/internal/ownedAttrs.ts +464 -0
- package/src/internal/passiveContentContract.ts +129 -0
- package/src/internal/pointerInteractionLease.ts +81 -0
- package/src/internal/runtimeContract.ts +194 -0
- package/src/internal/runtimeTheme.ts +159 -0
- package/src/internal/semanticSizing.ts +60 -0
- package/src/internal/useMenuLayer.ts +129 -0
- package/src/stores/useNotifierStore.ts +69 -0
- package/src/styles/reference.css +4 -0
- package/src/styles/style.css +151 -0
- package/src/styles/tokens.css +836 -0
- package/src/theme.ts +449 -0
- package/src/types/highlight-js-lib.d.ts +76 -0
- 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>
|