@pgcorp/ui-kit 0.8.1 → 0.10.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 (113) hide show
  1. package/README.md +332 -9
  2. package/docs/getting-started.md +135 -0
  3. package/docs/public-api.md +53 -4
  4. package/docs/rich-document.md +216 -0
  5. package/docs/scroll-viewport.md +167 -0
  6. package/docs/theming.md +17 -0
  7. package/package.json +108 -50
  8. package/src/components/layout/SAppShell.vue +3 -1
  9. package/src/components/layout/SDockRegion.vue +5 -3
  10. package/src/components/layout/SResizeHandle.vue +45 -5
  11. package/src/components/layout/SWorkbenchPanelShell.vue +4 -2
  12. package/src/components/shared/_internal/SDialogSurface.css +23 -0
  13. package/src/components/shared/_internal/SDialogSurface.vue +9 -2
  14. package/src/components/shared/_internal/SToastItem.css +4 -0
  15. package/src/components/shared/_internal/SToastItem.vue +44 -8
  16. package/src/components/shared/_internal/STreeNode.vue +24 -11
  17. package/src/components/shared/_internal/dialogSurface.ts +7 -1
  18. package/src/components/shared/complex/SKanbanLane.vue +4 -2
  19. package/src/components/shared/containers/SCloudChromeRow.vue +3 -2
  20. package/src/components/shared/containers/SDrawer.css +11 -2
  21. package/src/components/shared/containers/SDrawer.vue +48 -9
  22. package/src/components/shared/containers/SModal.vue +5 -2
  23. package/src/components/shared/containers/SPanel.vue +4 -2
  24. package/src/components/shared/containers/SPopover.vue +7 -3
  25. package/src/components/shared/containers/SUiKitLocaleProvider.vue +57 -0
  26. package/src/components/shared/controls/SButton.css +14 -10
  27. package/src/components/shared/controls/SCombobox.vue +8 -6
  28. package/src/components/shared/controls/SComboboxTrigger.css +7 -4
  29. package/src/components/shared/controls/SComboboxTrigger.vue +5 -2
  30. package/src/components/shared/controls/SContextToggleButton.vue +6 -4
  31. package/src/components/shared/controls/SDragHandle.vue +8 -4
  32. package/src/components/shared/controls/SDropdownMenu.vue +7 -4
  33. package/src/components/shared/controls/SInputText.css +5 -4
  34. package/src/components/shared/controls/SListbox.vue +4 -2
  35. package/src/components/shared/controls/SSelect.vue +4 -2
  36. package/src/components/shared/controls/STextarea.ts +13 -0
  37. package/src/components/shared/controls/STextarea.vue +391 -37
  38. package/src/components/shared/controls/_internal/SInlineTokenSurface.vue +4 -2
  39. package/src/components/shared/data-display/SChart.vue +7 -5
  40. package/src/components/shared/data-display/SChip.vue +3 -1
  41. package/src/components/shared/data-display/SCodeBlock.css +4 -0
  42. package/src/components/shared/data-display/SCodeBlock.vue +46 -22
  43. package/src/components/shared/data-display/SCodeEditor.css +17 -0
  44. package/src/components/shared/data-display/SCodeEditor.vue +173 -8
  45. package/src/components/shared/data-display/SCodeSearchPanel.vue +48 -33
  46. package/src/components/shared/data-display/SCodeWorkspaceEditor.css +13 -0
  47. package/src/components/shared/data-display/SCodeWorkspaceEditor.vue +355 -0
  48. package/src/components/shared/data-display/SControlledToastViewport.css +89 -0
  49. package/src/components/shared/data-display/SControlledToastViewport.vue +205 -0
  50. package/src/components/shared/data-display/SCopyField.vue +12 -10
  51. package/src/components/shared/data-display/SDiffViewer.css +122 -0
  52. package/src/components/shared/data-display/SDiffViewer.vue +499 -0
  53. package/src/components/shared/data-display/SDocBlock.vue +99 -71
  54. package/src/components/shared/data-display/SExpandableText.vue +10 -8
  55. package/src/components/shared/data-display/SJsonTree.vue +4 -2
  56. package/src/components/shared/data-display/SLinkedSystemsList.vue +4 -2
  57. package/src/components/shared/data-display/SMessage.vue +6 -6
  58. package/src/components/shared/data-display/SRichDocument.css +8 -0
  59. package/src/components/shared/data-display/SRichDocument.vue +65 -0
  60. package/src/components/shared/data-display/SScrollViewport.css +53 -0
  61. package/src/components/shared/data-display/SScrollViewport.vue +470 -0
  62. package/src/components/shared/data-display/STable.vue +38 -19
  63. package/src/components/shared/data-display/SToastContainer.vue +22 -17
  64. package/src/components/shared/data-display/SVirtualList.ts +33 -0
  65. package/src/components/shared/data-display/SVirtualList.vue +321 -36
  66. package/src/components/shared/data-display/_internal/SCodeTokenLine.css +72 -0
  67. package/src/components/shared/data-display/_internal/SCodeTokenLine.vue +79 -0
  68. package/src/components/shared/data-display/_internal/SRichBlockNode.css +85 -0
  69. package/src/components/shared/data-display/_internal/SRichBlockNode.vue +282 -0
  70. package/src/components/shared/data-display/_internal/SRichInlineNode.css +6 -0
  71. package/src/components/shared/data-display/_internal/SRichInlineNode.vue +76 -0
  72. package/src/components/shared/data-display/_internal/SRichMath.css +42 -0
  73. package/src/components/shared/data-display/_internal/SRichMath.vue +102 -0
  74. package/src/components/shared/data-display/codeEditor.ts +200 -0
  75. package/src/components/shared/data-display/codeWorkspace.ts +233 -0
  76. package/src/components/shared/data-display/diff.ts +541 -0
  77. package/src/components/shared/data-display/richDocument.ts +876 -0
  78. package/src/components/shared/data-display/scrollViewport.ts +47 -0
  79. package/src/components/shared/data-display/toast.ts +194 -0
  80. package/src/components/shared/database/SDataGrid.vue +7 -4
  81. package/src/components/shared/database/SSqlEditor.vue +39 -39
  82. package/src/components/shared/feedback/SAsyncState.vue +3 -1
  83. package/src/components/shared/graph/SGraphViewport.vue +11 -9
  84. package/src/components/shared/navigation/SBreadcrumbs.vue +8 -5
  85. package/src/components/shared/navigation/SCatalogNavigator.vue +26 -20
  86. package/src/components/shared/navigation/STabList.vue +10 -6
  87. package/src/components/shared/navigation/STabs.vue +15 -11
  88. package/src/components/shared/navigation/SWizardSteps.vue +4 -2
  89. package/src/components/shared/persona/SPersonaProjectPicker.vue +31 -27
  90. package/src/components/shared/persona/SPersonaRunWorkbench.vue +39 -33
  91. package/src/components/shared/persona/_internal/SPersonaRunConsole.vue +8 -6
  92. package/src/components/shared/persona/_internal/labels.ts +66 -57
  93. package/src/components/shared/persona/presentation.ts +140 -110
  94. package/src/composables/useFloatingPosition.ts +39 -2
  95. package/src/composables/useUiKitLocale.ts +17 -0
  96. package/src/internal/codeHighlight.ts +110 -0
  97. package/src/internal/diffEngine.ts +464 -0
  98. package/src/internal/diffRuntime.ts +189 -0
  99. package/src/internal/feedbackPresentation.ts +33 -14
  100. package/src/internal/localeContext.ts +22 -0
  101. package/src/internal/ownedAttrs.ts +8 -0
  102. package/src/internal/runtimeContract.ts +11 -9
  103. package/src/internal/semanticSizing.ts +13 -0
  104. package/src/locales/catalog.ts +634 -0
  105. package/src/locales/locale.ts +472 -0
  106. package/src/locales/messages.generated.ts +1826 -0
  107. package/src/locales/messages.ts +150 -0
  108. package/src/stores/useNotifierStore.ts +61 -44
  109. package/src/styles/tokens.css +23 -2
  110. package/src/theme.ts +59 -32
  111. package/src/workers/diff.worker.ts +73 -0
  112. package/src/workers/diffProtocol.ts +155 -0
  113. package/src/components/shared/data-display/SToastContainer.css +0 -53
@@ -4,8 +4,8 @@
4
4
  <div class="s-code-search-panel__field">
5
5
  <SInputText
6
6
  :model-value="searchValue"
7
- :placeholder="findPlaceholder"
8
- :aria-label="findPlaceholder"
7
+ :placeholder="labels.findPlaceholder"
8
+ :aria-label="labels.findPlaceholder"
9
9
  :invalid="validatedSearchError !== undefined"
10
10
  :error-message="validatedSearchError"
11
11
  name="search"
@@ -22,8 +22,8 @@
22
22
  size="sm"
23
23
  icon-only
24
24
  :leading-icon="ChevronUp"
25
- :title="previousLabel"
26
- :aria-label="previousLabel"
25
+ :title="labels.previousLabel"
26
+ :aria-label="labels.previousLabel"
27
27
  @click="emit('previous')"
28
28
  />
29
29
 
@@ -33,8 +33,8 @@
33
33
  size="sm"
34
34
  icon-only
35
35
  :leading-icon="ChevronDown"
36
- :title="nextLabel"
37
- :aria-label="nextLabel"
36
+ :title="labels.nextLabel"
37
+ :aria-label="labels.nextLabel"
38
38
  @click="emit('next')"
39
39
  />
40
40
 
@@ -45,8 +45,8 @@
45
45
  size="sm"
46
46
  icon-only
47
47
  :leading-icon="ScanSearch"
48
- :title="selectAllLabel"
49
- :aria-label="selectAllLabel"
48
+ :title="labels.selectAllLabel"
49
+ :aria-label="labels.selectAllLabel"
50
50
  @click="emit('selectAll')"
51
51
  />
52
52
 
@@ -57,8 +57,8 @@
57
57
  size="sm"
58
58
  icon-only
59
59
  :leading-icon="X"
60
- :title="closeLabel"
61
- :aria-label="closeLabel"
60
+ :title="labels.closeLabel"
61
+ :aria-label="labels.closeLabel"
62
62
  @click="emit('close')"
63
63
  />
64
64
  </div>
@@ -71,8 +71,8 @@
71
71
  size="sm"
72
72
  icon-only
73
73
  :leading-icon="CaseSensitive"
74
- :title="matchCaseLabel"
75
- :aria-label="matchCaseLabel"
74
+ :title="labels.matchCaseLabel"
75
+ :aria-label="labels.matchCaseLabel"
76
76
  :pressed="caseSensitive"
77
77
  @click="emit('update:caseSensitive', !caseSensitive)"
78
78
  />
@@ -83,8 +83,8 @@
83
83
  size="sm"
84
84
  icon-only
85
85
  :leading-icon="Regex"
86
- :title="regexpLabel"
87
- :aria-label="regexpLabel"
86
+ :title="labels.regexpLabel"
87
+ :aria-label="labels.regexpLabel"
88
88
  :pressed="regexp"
89
89
  @click="emit('update:regexp', !regexp)"
90
90
  />
@@ -95,8 +95,8 @@
95
95
  size="sm"
96
96
  icon-only
97
97
  :leading-icon="WholeWord"
98
- :title="wholeWordLabel"
99
- :aria-label="wholeWordLabel"
98
+ :title="labels.wholeWordLabel"
99
+ :aria-label="labels.wholeWordLabel"
100
100
  :pressed="wholeWord"
101
101
  @click="emit('update:wholeWord', !wholeWord)"
102
102
  />
@@ -109,8 +109,8 @@
109
109
  <div class="s-code-search-panel__field">
110
110
  <SInputText
111
111
  :model-value="replaceValue"
112
- :placeholder="replacePlaceholder"
113
- :aria-label="replacePlaceholder"
112
+ :placeholder="labels.replacePlaceholder"
113
+ :aria-label="labels.replacePlaceholder"
114
114
  :invalid="validatedReplaceError !== undefined"
115
115
  :error-message="validatedReplaceError"
116
116
  name="replace"
@@ -126,8 +126,8 @@
126
126
  size="sm"
127
127
  icon-only
128
128
  :leading-icon="Replace"
129
- :title="replaceLabel"
130
- :aria-label="replaceLabel"
129
+ :title="labels.replaceLabel"
130
+ :aria-label="labels.replaceLabel"
131
131
  @click="emit('replace')"
132
132
  />
133
133
 
@@ -138,8 +138,8 @@
138
138
  size="sm"
139
139
  icon-only
140
140
  :leading-icon="ReplaceAll"
141
- :title="replaceAllLabel"
142
- :aria-label="replaceAllLabel"
141
+ :title="labels.replaceAllLabel"
142
+ :aria-label="labels.replaceAllLabel"
143
143
  @click="emit('replaceAll')"
144
144
  />
145
145
  </div>
@@ -162,6 +162,7 @@ import {
162
162
  } from '../../icons/sputnigUiIcons';
163
163
  import { useOwnedAttrs } from '../../../internal/ownedAttrs';
164
164
  import { validateNonEmptyString } from '../../../internal/runtimeContract';
165
+ import { useUiKitLocale } from '../../../composables/useUiKitLocale';
165
166
 
166
167
  import SButton from '../controls/SButton.vue';
167
168
  import SInputText from '../controls/SInputText.vue';
@@ -192,20 +193,34 @@ export interface Props {
192
193
 
193
194
  const props = withDefaults(defineProps<Props>(), {
194
195
  canReplace: true,
195
- findPlaceholder: 'Найти',
196
- replacePlaceholder: 'Заменить',
197
- nextLabel: 'Дальше',
198
- previousLabel: 'Назад',
199
- selectAllLabel: 'Все',
200
- matchCaseLabel: 'С учётом регистра',
201
- regexpLabel: 'Регулярное выражение',
202
- wholeWordLabel: 'Слово целиком',
203
- replaceLabel: 'Заменить',
204
- replaceAllLabel: 'Заменить всё',
205
- closeLabel: 'Закрыть',
196
+ findPlaceholder: undefined,
197
+ replacePlaceholder: undefined,
198
+ nextLabel: undefined,
199
+ previousLabel: undefined,
200
+ selectAllLabel: undefined,
201
+ matchCaseLabel: undefined,
202
+ regexpLabel: undefined,
203
+ wholeWordLabel: undefined,
204
+ replaceLabel: undefined,
205
+ replaceAllLabel: undefined,
206
+ closeLabel: undefined,
206
207
  searchError: undefined,
207
208
  replaceError: undefined,
208
209
  });
210
+ const { t } = useUiKitLocale();
211
+ const labels = computed(() => ({
212
+ findPlaceholder: validateNonEmptyString('SCodeSearchPanel', 'findPlaceholder', props.findPlaceholder ?? t('editor.find')),
213
+ replacePlaceholder: validateNonEmptyString('SCodeSearchPanel', 'replacePlaceholder', props.replacePlaceholder ?? t('editor.replace')),
214
+ nextLabel: validateNonEmptyString('SCodeSearchPanel', 'nextLabel', props.nextLabel ?? t('editor.nextMatchShort')),
215
+ previousLabel: validateNonEmptyString('SCodeSearchPanel', 'previousLabel', props.previousLabel ?? t('editor.previousMatchShort')),
216
+ selectAllLabel: validateNonEmptyString('SCodeSearchPanel', 'selectAllLabel', props.selectAllLabel ?? t('editor.selectAllMatches')),
217
+ matchCaseLabel: validateNonEmptyString('SCodeSearchPanel', 'matchCaseLabel', props.matchCaseLabel ?? t('editor.matchCase')),
218
+ regexpLabel: validateNonEmptyString('SCodeSearchPanel', 'regexpLabel', props.regexpLabel ?? t('editor.regularExpression')),
219
+ wholeWordLabel: validateNonEmptyString('SCodeSearchPanel', 'wholeWordLabel', props.wholeWordLabel ?? t('editor.wholeWord')),
220
+ replaceLabel: validateNonEmptyString('SCodeSearchPanel', 'replaceLabel', props.replaceLabel ?? t('editor.replaceOne')),
221
+ replaceAllLabel: validateNonEmptyString('SCodeSearchPanel', 'replaceAllLabel', props.replaceAllLabel ?? t('editor.replaceAll')),
222
+ closeLabel: validateNonEmptyString('SCodeSearchPanel', 'closeLabel', props.closeLabel ?? t('actions.close')),
223
+ }));
209
224
 
210
225
  const emit = defineEmits<{
211
226
  (e: 'update:searchValue', value: string): void;
@@ -0,0 +1,13 @@
1
+ @reference "../../../styles/reference.css";
2
+
3
+ .s-code-workspace-editor {
4
+ @apply flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-surface-0 dark:bg-surface-900;
5
+ width: 100%;
6
+ height: 100%;
7
+ }
8
+
9
+ .s-code-workspace-editor[data-empty='true'] {
10
+ @apply items-center justify-center;
11
+ min-height: var(--s-code-editor-viewport-compact);
12
+ padding: var(--s-space-section-gap);
13
+ }
@@ -0,0 +1,355 @@
1
+ <template>
2
+ <div
3
+ v-bind="ownedAttrs.bindings()"
4
+ class="s-code-workspace-editor"
5
+ :data-empty="records.length === 0 || undefined"
6
+ >
7
+ <SEmptyState
8
+ v-if="records.length === 0"
9
+ :title="resolvedEmptyTitle"
10
+ size="sm"
11
+ presentation="inline"
12
+ />
13
+ <STabs
14
+ v-else
15
+ :model-value="validatedActiveKey ?? undefined"
16
+ :aria-label="validatedAriaLabel"
17
+ :controls-aria-label="resolvedTabActionsLabel"
18
+ tab-sizing="content"
19
+ tab-list-width="full"
20
+ tab-density="compact"
21
+ surface-radius="none"
22
+ grow
23
+ @update:model-value="activateDocument"
24
+ >
25
+ <template #list>
26
+ <STab
27
+ v-for="record in records"
28
+ :key="record.document.sessionKey"
29
+ :name="record.document.key"
30
+ :title="record.document.label"
31
+ >
32
+ {{ record.document.label }}
33
+ </STab>
34
+ </template>
35
+ <template v-if="activeRecord?.document.closable" #controls>
36
+ <SButton
37
+ label=""
38
+ :aria-label="closeLabel(activeRecord.document.label)"
39
+ :leading-icon="X"
40
+ appearance="ghost"
41
+ severity="secondary"
42
+ size="xs"
43
+ shape="square"
44
+ icon-only
45
+ @click="requestRelease(activeRecord.document)"
46
+ />
47
+ </template>
48
+ <template #panels>
49
+ <STabPanel
50
+ v-for="record in records"
51
+ :key="record.document.sessionKey"
52
+ :name="record.document.key"
53
+ overflow="hidden"
54
+ >
55
+ <SCodeEditor
56
+ :key="`${record.document.sessionKey}:${record.epoch}`"
57
+ :ref="(instance) => setEditorRef(record.document.sessionKey, instance)"
58
+ :model-value="record.document.value"
59
+ :language="record.document.language"
60
+ :capabilities="record.document.capabilities"
61
+ :diagnostics="record.document.diagnostics"
62
+ :read-only="record.document.readOnly"
63
+ :disabled="record.document.disabled"
64
+ :invalid="record.document.invalid"
65
+ :placeholder="record.document.placeholder"
66
+ :variant="record.document.variant"
67
+ :aria-label="record.document.label"
68
+ viewport="fill"
69
+ @update:model-value="emitDocumentChange(record, $event)"
70
+ @selection-change="emitSelectionChange(record, $event)"
71
+ @view-state-change="emitViewStateChange(record, $event)"
72
+ />
73
+ </STabPanel>
74
+ </template>
75
+ </STabs>
76
+ </div>
77
+ </template>
78
+
79
+ <script setup lang="ts">
80
+ import { computed, defineAsyncComponent, onBeforeUnmount, shallowRef, watch } from 'vue'
81
+ import { X } from '../../icons/sputnigUiIcons'
82
+ import { useOwnedAttrs } from '../../../internal/ownedAttrs'
83
+ import { useUiKitLocale } from '../../../composables/useUiKitLocale'
84
+ import { validateNonEmptyString } from '../../../internal/runtimeContract'
85
+ import SButton from '../controls/SButton.vue'
86
+ import STab from '../navigation/STab.vue'
87
+ import STabPanel from '../navigation/STabPanel.vue'
88
+ import STabs from '../navigation/STabs.vue'
89
+ import SEmptyState from './SEmptyState.vue'
90
+ import type {
91
+ SCodeEditorApi,
92
+ SCodeEditorSelectionRange,
93
+ SCodeEditorViewState,
94
+ } from './codeEditor'
95
+ import {
96
+ validateCodeWorkspaceActiveKey,
97
+ validateCodeWorkspaceDocuments,
98
+ type SCodeWorkspaceDocument,
99
+ type SCodeWorkspaceDocumentChange,
100
+ type SCodeWorkspaceDocumentKey,
101
+ type SCodeWorkspaceDocumentState,
102
+ type SCodeWorkspaceEditorApi,
103
+ type SCodeWorkspaceReleaseRequest,
104
+ type SCodeWorkspaceSelectionChange,
105
+ type SCodeWorkspaceSessionKey,
106
+ type SCodeWorkspaceViewStateChange,
107
+ } from './codeWorkspace'
108
+
109
+ defineOptions({ inheritAttrs: false })
110
+
111
+ const SCodeEditor = defineAsyncComponent(() => import('../../../internal/lazyCodeEditor'))
112
+
113
+ /** Keyed editor workspace. Consumer owns text/revisions; UI-kit owns mounted editor-session resources. */
114
+ export interface Props {
115
+ /** Current controlled document inventory. Sessions must be explicitly released before removal. */
116
+ documents: readonly SCodeWorkspaceDocument[]
117
+ /** Controlled active document key, or null for an empty inventory. */
118
+ activeKey: SCodeWorkspaceDocumentKey | null
119
+ /** Accessible name for the workspace tab list. */
120
+ ariaLabel: string
121
+ /** Optional empty-state title. */
122
+ emptyTitle?: string
123
+ }
124
+
125
+ interface SessionRecord {
126
+ readonly document: SCodeWorkspaceDocument
127
+ readonly epoch: number
128
+ }
129
+
130
+ const props = withDefaults(defineProps<Props>(), {
131
+ emptyTitle: undefined,
132
+ })
133
+ const emit = defineEmits<{
134
+ 'update:activeKey': [key: SCodeWorkspaceDocumentKey]
135
+ 'document-change': [payload: SCodeWorkspaceDocumentChange]
136
+ 'selection-change': [payload: SCodeWorkspaceSelectionChange]
137
+ 'view-state-change': [payload: SCodeWorkspaceViewStateChange]
138
+ 'release-request': [payload: SCodeWorkspaceReleaseRequest]
139
+ }>()
140
+
141
+ const ownedAttrs = useOwnedAttrs({
142
+ component: 'SCodeWorkspaceEditor',
143
+ owner: 'keyed code-editor workspace root',
144
+ })
145
+ const { t } = useUiKitLocale()
146
+ const records = shallowRef<readonly SessionRecord[]>([])
147
+ const releasedSessionKeys = new Set<SCodeWorkspaceSessionKey>()
148
+ const editorRefs = new Map<SCodeWorkspaceSessionKey, SCodeEditorApi>()
149
+
150
+ const validatedDocuments = computed(() => validateCodeWorkspaceDocuments(
151
+ 'SCodeWorkspaceEditor',
152
+ props.documents,
153
+ ))
154
+ const validatedActiveKey = computed(() => validateCodeWorkspaceActiveKey(
155
+ 'SCodeWorkspaceEditor',
156
+ props.activeKey,
157
+ validatedDocuments.value,
158
+ ))
159
+ const activeRecord = computed(() => records.value.find(
160
+ (record) => record.document.key === validatedActiveKey.value,
161
+ ))
162
+ const validatedAriaLabel = computed(() => validateNonEmptyString(
163
+ 'SCodeWorkspaceEditor',
164
+ 'ariaLabel',
165
+ props.ariaLabel,
166
+ ))
167
+ const resolvedEmptyTitle = computed(() => props.emptyTitle === undefined
168
+ ? t('editor.workspaceEmpty')
169
+ : validateNonEmptyString('SCodeWorkspaceEditor', 'emptyTitle', props.emptyTitle))
170
+ const resolvedTabActionsLabel = computed(() => t('navigation.tabActions'))
171
+
172
+ function sameRevision(
173
+ left: SCodeWorkspaceDocument['revision'],
174
+ right: SCodeWorkspaceDocument['revision'],
175
+ ): boolean {
176
+ return typeof left === typeof right && left === right
177
+ }
178
+
179
+ function synchronizeSessions(documents: readonly SCodeWorkspaceDocument[]): void {
180
+ const previousBySession = new Map(records.value.map((record) => [record.document.sessionKey, record]))
181
+ const nextSessionKeys = new Set(documents.map((document) => document.sessionKey))
182
+ const missing = records.value.find((record) => !nextSessionKeys.has(record.document.sessionKey))
183
+ if (missing) {
184
+ throw new Error(
185
+ `SCodeWorkspaceEditor: session ${JSON.stringify(missing.document.sessionKey)} удалена из documents без releaseSession(). `
186
+ + `/ SCodeWorkspaceEditor: session ${JSON.stringify(missing.document.sessionKey)} was removed from documents without releaseSession().`,
187
+ )
188
+ }
189
+
190
+ records.value = documents.map((document) => {
191
+ if (releasedSessionKeys.has(document.sessionKey)) {
192
+ throw new Error(
193
+ `SCodeWorkspaceEditor: sessionKey ${JSON.stringify(document.sessionKey)} уже освобождён; откройте документ с новым sessionKey. `
194
+ + `/ SCodeWorkspaceEditor: sessionKey ${JSON.stringify(document.sessionKey)} was released; reopen with a new sessionKey.`,
195
+ )
196
+ }
197
+ const previous = previousBySession.get(document.sessionKey)
198
+ if (!previous) return Object.freeze({ document, epoch: 0 })
199
+ if (previous.document.key !== document.key) {
200
+ throw new Error(
201
+ `SCodeWorkspaceEditor: sessionKey ${JSON.stringify(document.sessionKey)} не может менять document key. `
202
+ + `/ SCodeWorkspaceEditor: sessionKey ${JSON.stringify(document.sessionKey)} cannot change document key.`,
203
+ )
204
+ }
205
+ const epoch = sameRevision(previous.document.revision, document.revision)
206
+ ? previous.epoch
207
+ : previous.epoch + 1
208
+ return Object.freeze({ document, epoch })
209
+ })
210
+ }
211
+
212
+ watch(validatedDocuments, synchronizeSessions, { immediate: true })
213
+ watch(validatedActiveKey, () => undefined, { immediate: true })
214
+
215
+ function isCodeEditorApi(value: unknown): value is SCodeEditorApi {
216
+ if (typeof value !== 'object' || value === null) return false
217
+ const candidate = value as Partial<Record<keyof SCodeEditorApi, unknown>>
218
+ return typeof candidate.applySelection === 'function'
219
+ && typeof candidate.revealRange === 'function'
220
+ && typeof candidate.getSelection === 'function'
221
+ && typeof candidate.captureViewState === 'function'
222
+ && typeof candidate.applyViewState === 'function'
223
+ && typeof candidate.focus === 'function'
224
+ }
225
+
226
+ function setEditorRef(sessionKey: SCodeWorkspaceSessionKey, instance: unknown): void {
227
+ if (instance === null) {
228
+ editorRefs.delete(sessionKey)
229
+ return
230
+ }
231
+ if (!isCodeEditorApi(instance)) {
232
+ throw new TypeError('SCodeWorkspaceEditor: SCodeEditor ref does not implement the public SCodeEditorApi.')
233
+ }
234
+ editorRefs.set(sessionKey, instance)
235
+ }
236
+
237
+ function requireRecordByKey(key: SCodeWorkspaceDocumentKey): SessionRecord {
238
+ const validatedKey = validateNonEmptyString('SCodeWorkspaceEditor', 'document key', key)
239
+ const record = records.value.find((candidate) => candidate.document.key === validatedKey)
240
+ if (!record) {
241
+ throw new RangeError(
242
+ `SCodeWorkspaceEditor: document key ${JSON.stringify(validatedKey)} отсутствует. `
243
+ + `/ SCodeWorkspaceEditor: document key ${JSON.stringify(validatedKey)} is unavailable.`,
244
+ )
245
+ }
246
+ return record
247
+ }
248
+
249
+ function requireEditor(record: SessionRecord): SCodeEditorApi {
250
+ const editor = editorRefs.get(record.document.sessionKey)
251
+ if (!editor) {
252
+ throw new Error(
253
+ `SCodeWorkspaceEditor: editor session ${JSON.stringify(record.document.sessionKey)} не готова. `
254
+ + `/ SCodeWorkspaceEditor: editor session ${JSON.stringify(record.document.sessionKey)} is not ready.`,
255
+ )
256
+ }
257
+ return editor
258
+ }
259
+
260
+ function activateDocument(key: SCodeWorkspaceDocumentKey): void {
261
+ requireRecordByKey(key)
262
+ emit('update:activeKey', key)
263
+ }
264
+
265
+ function closeLabel(label: string): string {
266
+ return t('editor.closeDocument', { name: label })
267
+ }
268
+
269
+ function requestRelease(document: SCodeWorkspaceDocument): void {
270
+ emit('release-request', Object.freeze({
271
+ key: document.key,
272
+ sessionKey: document.sessionKey,
273
+ }))
274
+ }
275
+
276
+ function emitDocumentChange(record: SessionRecord, value: string): void {
277
+ emit('document-change', Object.freeze({
278
+ key: record.document.key,
279
+ sessionKey: record.document.sessionKey,
280
+ revision: record.document.revision,
281
+ value,
282
+ }))
283
+ }
284
+
285
+ function emitSelectionChange(record: SessionRecord, selection: SCodeEditorSelectionRange): void {
286
+ emit('selection-change', Object.freeze({
287
+ key: record.document.key,
288
+ sessionKey: record.document.sessionKey,
289
+ selection,
290
+ }))
291
+ }
292
+
293
+ function emitViewStateChange(record: SessionRecord, viewState: SCodeEditorViewState): void {
294
+ emit('view-state-change', Object.freeze({
295
+ key: record.document.key,
296
+ sessionKey: record.document.sessionKey,
297
+ viewState,
298
+ }))
299
+ }
300
+
301
+ function releaseSession(sessionKey: SCodeWorkspaceSessionKey): void {
302
+ const validatedSessionKey = validateNonEmptyString('SCodeWorkspaceEditor', 'sessionKey', sessionKey)
303
+ if (releasedSessionKeys.has(validatedSessionKey)) {
304
+ throw new Error(
305
+ `SCodeWorkspaceEditor: session ${JSON.stringify(validatedSessionKey)} уже освобождена. `
306
+ + `/ SCodeWorkspaceEditor: session ${JSON.stringify(validatedSessionKey)} is already released.`,
307
+ )
308
+ }
309
+ if (!records.value.some((record) => record.document.sessionKey === validatedSessionKey)) {
310
+ throw new RangeError(
311
+ `SCodeWorkspaceEditor: session ${JSON.stringify(validatedSessionKey)} отсутствует. `
312
+ + `/ SCodeWorkspaceEditor: session ${JSON.stringify(validatedSessionKey)} is unavailable.`,
313
+ )
314
+ }
315
+ releasedSessionKeys.add(validatedSessionKey)
316
+ editorRefs.delete(validatedSessionKey)
317
+ records.value = records.value.filter((record) => record.document.sessionKey !== validatedSessionKey)
318
+ }
319
+
320
+ const api: SCodeWorkspaceEditorApi = {
321
+ focusActive: () => {
322
+ if (validatedActiveKey.value === null) {
323
+ throw new Error('SCodeWorkspaceEditor: пустой workspace не имеет active editor. / SCodeWorkspaceEditor: an empty workspace has no active editor.')
324
+ }
325
+ const record = requireRecordByKey(validatedActiveKey.value)
326
+ requireEditor(record).focus()
327
+ },
328
+ revealRange: (key, range) => {
329
+ const record = requireRecordByKey(key)
330
+ requireEditor(record).revealRange(range)
331
+ },
332
+ captureDocumentState: (key): SCodeWorkspaceDocumentState => {
333
+ const record = requireRecordByKey(key)
334
+ const editor = requireEditor(record)
335
+ return Object.freeze({
336
+ key: record.document.key,
337
+ sessionKey: record.document.sessionKey,
338
+ revision: record.document.revision,
339
+ selection: editor.getSelection(),
340
+ viewState: editor.captureViewState(),
341
+ })
342
+ },
343
+ releaseSession,
344
+ }
345
+
346
+ defineExpose<SCodeWorkspaceEditorApi>(api)
347
+
348
+ onBeforeUnmount(() => {
349
+ editorRefs.clear()
350
+ releasedSessionKeys.clear()
351
+ records.value = []
352
+ })
353
+ </script>
354
+
355
+ <style lang="postcss" src="./SCodeWorkspaceEditor.css" scoped></style>
@@ -0,0 +1,89 @@
1
+ @reference "../../../styles/reference.css";
2
+
3
+ .s-toast-container {
4
+ @apply fixed flex max-h-[100dvh] min-w-0 w-full max-w-md flex-col overflow-y-auto overscroll-contain p-4;
5
+ z-index: var(--s-layer-root-toast);
6
+ pointer-events: none;
7
+ }
8
+
9
+ .s-toast-container[data-position^='top-'] {
10
+ inset-block-start: 0;
11
+ }
12
+
13
+ .s-toast-container[data-position^='bottom-'] {
14
+ inset-block-end: 0;
15
+ }
16
+
17
+ .s-toast-container[data-position$='-start'] {
18
+ inset-inline-start: 0;
19
+ }
20
+
21
+ .s-toast-container[data-position$='-end'] {
22
+ inset-inline-end: 0;
23
+ }
24
+
25
+ .s-toast-container[data-position$='-center'] {
26
+ inset-inline: 0;
27
+ margin-inline: auto;
28
+ }
29
+
30
+ .s-toast-transition-wrapper {
31
+ @apply relative flex flex-col gap-2;
32
+ }
33
+
34
+ .s-toast-container[data-position$='-start'] .s-toast-transition-wrapper {
35
+ align-items: flex-start;
36
+ }
37
+
38
+ .s-toast-container[data-position$='-center'] .s-toast-transition-wrapper {
39
+ align-items: center;
40
+ }
41
+
42
+ .s-toast-container[data-position$='-end'] .s-toast-transition-wrapper {
43
+ align-items: flex-end;
44
+ }
45
+
46
+ .notification-transition-enter-active {
47
+ transition:
48
+ opacity var(--s-motion-standard) ease-out,
49
+ transform var(--s-motion-standard) ease-out;
50
+ }
51
+
52
+ .notification-transition-enter-from {
53
+ opacity: 0;
54
+ transform: translate3d(0, 0.75rem, 0) scale(0.98);
55
+ }
56
+
57
+ .notification-transition-enter-to {
58
+ opacity: 1;
59
+ transform: translate3d(0, 0, 0) scale(1);
60
+ }
61
+
62
+ .notification-transition-leave-active {
63
+ position: absolute;
64
+ transition:
65
+ opacity var(--s-motion-fast) ease-in,
66
+ transform var(--s-motion-fast) ease-in;
67
+ }
68
+
69
+ .notification-transition-leave-from {
70
+ opacity: 1;
71
+ transform: translate3d(0, 0, 0) scale(1);
72
+ }
73
+
74
+ .notification-transition-leave-to {
75
+ opacity: 0;
76
+ transform: translate3d(0, 0.5rem, 0) scale(0.98);
77
+ }
78
+
79
+ .notification-transition-move {
80
+ transition: transform var(--s-motion-standard) ease;
81
+ }
82
+
83
+ @media (prefers-reduced-motion: reduce) {
84
+ .notification-transition-enter-active,
85
+ .notification-transition-leave-active,
86
+ .notification-transition-move {
87
+ transition-duration: 0.01ms;
88
+ }
89
+ }