@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
@@ -41,6 +41,7 @@
41
41
  <script setup lang="ts">
42
42
  import { computed, type Component } from 'vue'
43
43
  import { X } from '../../icons/sputnigUiIcons'
44
+ import { useUiKitLocale } from '../../../composables/useUiKitLocale'
44
45
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
45
46
  import {
46
47
  PILL_ICON_CLASS,
@@ -89,6 +90,7 @@ const emit = defineEmits<{
89
90
  activate: [event: MouseEvent]
90
91
  }>()
91
92
  const ownedAttrs = useOwnedAttrs({ component: 'SChip', owner: 'chip root' })
93
+ const { t } = useUiKitLocale()
92
94
 
93
95
  const validatedLabel = computed(() => validateNonEmptyString('SChip', 'label', props.label))
94
96
  const validatedSeverity = computed(() => validateExactString(
@@ -120,7 +122,7 @@ const resolvedCloseLabel = computed(() => {
120
122
  validateBoolean('SChip', 'closable', props.closable)
121
123
  validateBoolean('SChip', 'disabled', props.disabled)
122
124
  return props.closeLabel === undefined
123
- ? `Удалить «${validatedLabel.value}»`
125
+ ? t('actions.removeChip', { label: validatedLabel.value })
124
126
  : validateNonEmptyString('SChip', 'closeLabel', props.closeLabel)
125
127
  })
126
128
  </script>
@@ -111,6 +111,10 @@
111
111
  grid-template-columns: max-content minmax(0, 1fr);
112
112
  }
113
113
 
114
+ .s-code-block-plain__line[data-line-numbers='hidden'] {
115
+ display: block;
116
+ }
117
+
114
118
  .s-code-block-plain__line-number {
115
119
  min-width: 2.5rem;
116
120
  padding-inline-end: 0.75rem;
@@ -22,8 +22,8 @@
22
22
  size="xs"
23
23
  :leading-icon="Copy"
24
24
  icon-only
25
- title="Копировать код"
26
- aria-label="Копировать код"
25
+ :title="t('clipboard.copyCode')"
26
+ :aria-label="t('clipboard.copyCode')"
27
27
  label=""
28
28
  @click="copyCode"
29
29
  />
@@ -38,8 +38,7 @@
38
38
  :capabilities="editorCapabilities"
39
39
  :read-only="validatedReadOnly"
40
40
  :viewport="validatedViewport"
41
- :aria-label="props.ariaLabel"
42
- :aria-labelledby="props.ariaLabelledby"
41
+ v-bind="editorAccessibleName"
43
42
  :placeholder="props.placeholder"
44
43
  :next-selection-pos="props.nextSelectionPos"
45
44
  @update:model-value="emit('update:code', $event)"
@@ -48,7 +47,7 @@
48
47
  @ready="onReady"
49
48
  />
50
49
  <pre
51
- v-else
50
+ v-else-if="validatedPresentation === 'plain'"
52
51
  :ref="setPlainSurface"
53
52
  class="s-code-block-plain"
54
53
  :data-viewport="validatedViewport"
@@ -62,11 +61,30 @@
62
61
  :key="index"
63
62
  class="s-code-block-plain__line"
64
63
  ><span class="s-code-block-plain__line-number" aria-hidden="true">{{ index + 1 }}</span><span class="s-code-block-plain__line-content">{{ line || '\u200b' }}</span></span></code></pre>
64
+ <pre
65
+ v-else
66
+ :ref="setPlainSurface"
67
+ class="s-code-block-plain"
68
+ :data-viewport="validatedViewport"
69
+ :aria-label="props.ariaLabel"
70
+ :aria-labelledby="props.ariaLabelledby"
71
+ :style="{ tabSize: validatedTabSize }"
72
+ tabindex="0"
73
+ ><span class="s-code-block-plain__lines"><span
74
+ v-for="(line, index) in plainLines"
75
+ :key="index"
76
+ class="s-code-block-plain__line"
77
+ :data-line-numbers="validatedShowLineNumbers ? 'visible' : 'hidden'"
78
+ ><span v-if="validatedShowLineNumbers" class="s-code-block-plain__line-number" aria-hidden="true">{{ index + 1 }}</span><span class="s-code-block-plain__line-content"><SCodeTokenLine
79
+ :text="line || '\u200b'"
80
+ :language="validatedLanguage"
81
+ variant="code-block"
82
+ /></span></span></span></pre>
65
83
  </div>
66
84
  <SAsyncState
67
85
  v-if="validatedErrorMessage !== undefined || (validatedPresentation === 'editor' && !isReady)"
68
86
  :state="validatedErrorMessage === undefined
69
- ? { status: 'loading', message: 'Загрузка кода...' }
87
+ ? { status: 'loading', message: t('editor.loading') }
70
88
  : { status: 'error', message: validatedErrorMessage }"
71
89
  presentation="overlay"
72
90
  />
@@ -81,6 +99,7 @@ import { validateCodeEditorLanguage, validateCodeEditorViewport } from '../../..
81
99
  import { resolveCodeLanguageKey } from '../../../internal/codeLanguageIdentity';
82
100
  import { useNotifier } from '../../../composables/useNotifier';
83
101
  import { useClipboard } from '../../../composables/useClipboard';
102
+ import { useUiKitLocale } from '../../../composables/useUiKitLocale';
84
103
  import SButton from '../controls/SButton.vue';
85
104
  import type {
86
105
  SCodeEditorCapabilities,
@@ -92,6 +111,7 @@ import type {
92
111
  } from './SCodeEditor.vue';
93
112
  import { Copy } from '../../icons/sputnigUiIcons';
94
113
  import SAsyncState from '../feedback/SAsyncState.vue';
114
+ import SCodeTokenLine from './_internal/SCodeTokenLine.vue';
95
115
 
96
116
  defineOptions({ inheritAttrs: false });
97
117
 
@@ -103,7 +123,7 @@ type AccessibleName =
103
123
  export type Props = AccessibleName & {
104
124
  code: string;
105
125
  language: SCodeEditorLanguage;
106
- presentation?: 'editor' | 'plain';
126
+ presentation?: 'editor' | 'plain' | 'highlighted';
107
127
  showHeader?: boolean;
108
128
  showLineNumbers?: boolean;
109
129
  readOnly?: boolean;
@@ -137,6 +157,9 @@ const codeEditor = ref<InstanceType<typeof import('./SCodeEditor.vue')['default'
137
157
  const plainSurface = ref<HTMLElement | null>(null);
138
158
  const AsyncSCodeEditor = defineAsyncComponent(() => import('../../../internal/lazyCodeEditor'));
139
159
  const ownedAttrs = useOwnedAttrs({ component: 'SCodeBlock', owner: 'code block root' });
160
+ const editorAccessibleName = computed<AccessibleName>(() => props.ariaLabel !== undefined
161
+ ? { ariaLabel: props.ariaLabel }
162
+ : { ariaLabelledby: props.ariaLabelledby });
140
163
  const emit = defineEmits<{
141
164
  (e: 'cursor-activity', payload: { pos: number; lineFrom: number; lineTo: number; lineText: string; cursorCol: number }): void
142
165
  (e: 'update:code', value: string): void
@@ -148,7 +171,7 @@ const validatedShowHeader = computed(() => validateBoolean('SCodeBlock', 'showHe
148
171
  const validatedShowLineNumbers = computed(() => validateBoolean('SCodeBlock', 'showLineNumbers', props.showLineNumbers));
149
172
  const validatedReadOnly = computed(() => validateBoolean('SCodeBlock', 'readOnly', props.readOnly));
150
173
  const validatedPresentation = computed(() => validateExactString(
151
- 'SCodeBlock', 'presentation', props.presentation, ['editor', 'plain'] as const,
174
+ 'SCodeBlock', 'presentation', props.presentation, ['editor', 'plain', 'highlighted'] as const,
152
175
  ));
153
176
  const validatedSurfaceRadius = computed(() => validateExactString(
154
177
  'SCodeBlock', 'surfaceRadius', props.surfaceRadius, ['default', 'none', 'xl'] as const,
@@ -198,7 +221,7 @@ const emitPlainReady = (): void => {
198
221
 
199
222
  const syncPresentation = (): void => {
200
223
  if (!mounted) return;
201
- if (validatedPresentation.value === 'plain') emitPlainReady();
224
+ if (validatedPresentation.value !== 'editor') emitPlainReady();
202
225
  else isReady.value = false;
203
226
  };
204
227
 
@@ -211,14 +234,14 @@ watch(validatedPresentation, syncPresentation);
211
234
  watch(
212
235
  [validatedPresentation, validatedReadOnly],
213
236
  ([presentation, readOnly]) => {
214
- if (presentation === 'plain' && !readOnly) {
215
- throw new TypeError('SCodeBlock: presentation="plain" поддерживает только readOnly=true. / SCodeBlock: presentation="plain" supports readOnly=true only.');
237
+ if (presentation !== 'editor' && !readOnly) {
238
+ throw new TypeError(`SCodeBlock: presentation=${JSON.stringify(presentation)} поддерживает только readOnly=true. / SCodeBlock: presentation=${JSON.stringify(presentation)} supports readOnly=true only.`);
216
239
  }
217
- if (presentation === 'plain' && props.interceptKeys !== undefined) {
218
- throw new TypeError('SCodeBlock: presentation="plain" не принимает interceptKeys. / SCodeBlock: presentation="plain" does not accept interceptKeys.');
240
+ if (presentation !== 'editor' && props.interceptKeys !== undefined) {
241
+ throw new TypeError(`SCodeBlock: presentation=${JSON.stringify(presentation)} не принимает interceptKeys. / SCodeBlock: presentation=${JSON.stringify(presentation)} does not accept interceptKeys.`);
219
242
  }
220
- if (presentation === 'plain' && props.nextSelectionPos !== null) {
221
- throw new TypeError('SCodeBlock: presentation="plain" не принимает nextSelectionPos. / SCodeBlock: presentation="plain" does not accept nextSelectionPos.');
243
+ if (presentation !== 'editor' && props.nextSelectionPos !== null) {
244
+ throw new TypeError(`SCodeBlock: presentation=${JSON.stringify(presentation)} не принимает nextSelectionPos. / SCodeBlock: presentation=${JSON.stringify(presentation)} does not accept nextSelectionPos.`);
222
245
  }
223
246
  },
224
247
  { immediate: true },
@@ -232,8 +255,8 @@ const setPlainSurface = (element: Element | ComponentPublicInstance | null): voi
232
255
  };
233
256
 
234
257
  const applySelection = (position: number): void => {
235
- if (validatedPresentation.value === 'plain') {
236
- throw new Error('SCodeBlock: applySelection недоступен в presentation="plain". / SCodeBlock: applySelection is unavailable in presentation="plain".');
258
+ if (validatedPresentation.value !== 'editor') {
259
+ throw new Error(`SCodeBlock: applySelection недоступен в presentation=${JSON.stringify(validatedPresentation.value)}. / SCodeBlock: applySelection is unavailable in presentation=${JSON.stringify(validatedPresentation.value)}.`);
237
260
  }
238
261
  const editor = codeEditor.value;
239
262
  if (!editor) throw new Error('SCodeBlock: editor не готов; дождитесь ready. / SCodeBlock: editor is not ready; wait for ready.');
@@ -241,7 +264,7 @@ const applySelection = (position: number): void => {
241
264
  };
242
265
 
243
266
  const focus = (): void => {
244
- if (validatedPresentation.value === 'plain') {
267
+ if (validatedPresentation.value !== 'editor') {
245
268
  const surface = plainSurface.value;
246
269
  if (!surface) throw new Error('SCodeBlock: plain surface не готов; дождитесь ready. / SCodeBlock: plain surface is not ready; wait for ready.');
247
270
  surface.focus();
@@ -254,19 +277,20 @@ const focus = (): void => {
254
277
 
255
278
  defineExpose({ applySelection, focus });
256
279
  const notifier = useNotifier();
280
+ const { t } = useUiKitLocale();
257
281
  const { copyText } = useClipboard();
258
282
  const copyCode = async () => {
259
283
  const result = await copyText(props.code);
260
284
  if (result.status === 'copied') {
261
- notifier.success({ message: 'Код скопирован' });
285
+ notifier.success({ message: t('clipboard.codeCopied') });
262
286
  return;
263
287
  }
264
288
  notifier.danger({
265
289
  message: result.status === 'unavailable'
266
- ? 'Копирование недоступно в этом браузере'
290
+ ? t('clipboard.codeCopyUnavailable')
267
291
  : result.status === 'empty'
268
- ? 'Нет кода для копирования'
269
- : `Ошибка копирования. ${result.error.message}`,
292
+ ? t('clipboard.emptyCode')
293
+ : `${t('clipboard.codeCopyFailed')} ${result.error.message}`,
270
294
  });
271
295
  };
272
296
  </script>
@@ -87,6 +87,23 @@
87
87
  @apply text-surface-400 dark:text-surface-500;
88
88
  }
89
89
 
90
+ .s-code-editor :deep(.cm-lintRange-error) {
91
+ text-decoration-color: var(--s-code-editor-diagnostic-error);
92
+ }
93
+
94
+ .s-code-editor :deep(.cm-lintRange-warning) {
95
+ text-decoration-color: var(--s-code-editor-diagnostic-warning);
96
+ }
97
+
98
+ .s-code-editor :deep(.cm-lintRange-info),
99
+ .s-code-editor :deep(.cm-lintRange-hint) {
100
+ text-decoration-color: var(--s-code-editor-diagnostic-info);
101
+ }
102
+
103
+ .s-code-editor__diagnostics {
104
+ @apply sr-only;
105
+ }
106
+
90
107
  .s-code-editor :deep(.cm-tooltip) {
91
108
  @apply rounded-md border border-surface-200 bg-surface-0 text-surface-900 shadow-lg;
92
109
  @apply dark:border-surface-700 dark:bg-surface-800 dark:text-surface-100;
@@ -13,13 +13,28 @@
13
13
  @ready="onReady"
14
14
  @update:model-value="onUpdate"
15
15
  />
16
+ <div
17
+ v-if="validatedDiagnostics.length > 0"
18
+ :id="diagnosticsOwnerId"
19
+ class="s-code-editor__diagnostics"
20
+ role="status"
21
+ aria-live="polite"
22
+ aria-atomic="true"
23
+ >
24
+ <ul>
25
+ <li v-for="diagnostic in validatedDiagnostics" :key="diagnostic.id">
26
+ {{ diagnostic.message }}
27
+ </li>
28
+ </ul>
29
+ </div>
16
30
  </template>
17
31
 
18
32
  <script setup lang="ts">
19
- import { computed, ref, watch } from 'vue'
33
+ import { computed, onBeforeUnmount, ref, shallowRef, useId, watch } from 'vue'
20
34
  import { Codemirror } from 'vue-codemirror'
21
35
  import { oneDark } from '@codemirror/theme-one-dark'
22
36
  import { EditorSelection, EditorState, type Extension } from '@codemirror/state'
37
+ import { lintGutter, setDiagnostics, type Diagnostic as CodeMirrorDiagnostic } from '@codemirror/lint'
23
38
  import { EditorView, lineNumbers, placeholder as codeMirrorPlaceholder } from '@codemirror/view'
24
39
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
25
40
  import { useInteractiveLeafRegistration } from '../../../internal/passiveContentContract'
@@ -42,6 +57,18 @@ import {
42
57
  validateCodeEditorViewport,
43
58
  } from '../../../internal/codeEditorContract'
44
59
  import { getCodeMirrorLanguageExtension, resolveCodeLanguageKey } from '../codeLanguages'
60
+ import {
61
+ type SCodeEditorDiagnostic,
62
+ type SCodeEditorDiagnosticId,
63
+ type SCodeEditorApi,
64
+ type SCodeEditorRange,
65
+ type SCodeEditorSelectionRange,
66
+ type SCodeEditorSelectionSnapshot,
67
+ type SCodeEditorViewState,
68
+ validateCodeEditorDiagnostics,
69
+ validateCodeEditorRange,
70
+ validateCodeEditorViewState,
71
+ } from './codeEditor'
45
72
 
46
73
  defineOptions({ inheritAttrs: false })
47
74
 
@@ -92,6 +119,8 @@ export type Props = AccessibleName & {
92
119
  viewport?: SCodeEditorViewport
93
120
  variant?: SCodeEditorVariant
94
121
  nextSelectionPos?: number | null
122
+ /** Controlled diagnostics expressed only through serializable UI-kit ranges. */
123
+ diagnostics?: readonly SCodeEditorDiagnostic<SCodeEditorDiagnosticId>[]
95
124
  /** DOM id supplemental status/diagnostic owner. / DOM id of a supplemental status/diagnostic owner. */
96
125
  ariaDescribedby?: string
97
126
  /** Помечает editor content невалидным без владения presentation диагностики. / Marks the editor content as invalid without owning diagnostic presentation. */
@@ -106,6 +135,7 @@ const props = withDefaults(defineProps<Props>(), {
106
135
  viewport: 'content',
107
136
  variant: 'code',
108
137
  nextSelectionPos: null,
138
+ diagnostics: () => [],
109
139
  ariaDescribedby: undefined,
110
140
  invalid: false,
111
141
  })
@@ -113,6 +143,8 @@ const props = withDefaults(defineProps<Props>(), {
113
143
  const emit = defineEmits<{
114
144
  'update:modelValue': [value: string]
115
145
  'cursor-activity': [payload: SCodeEditorCursorActivity]
146
+ 'selection-change': [payload: SCodeEditorSelectionRange]
147
+ 'view-state-change': [payload: SCodeEditorViewState]
116
148
  'selection-applied': [payload: SCodeEditorSelectionAppliedPayload]
117
149
  ready: [payload: SCodeEditorReadyPayload]
118
150
  }>()
@@ -122,8 +154,12 @@ useInteractiveLeafRegistration({ owner: 'SCodeEditor' })
122
154
 
123
155
  const runtimeTheme = useRuntimeTheme()
124
156
  const trustedChannel = useTrustedCodeEditorExtensions()
125
- const editorView = ref<EditorView | null>(null)
157
+ const editorView = shallowRef<EditorView | null>(null)
126
158
  const pendingControlledSelection = ref<number | null>(null)
159
+ const diagnosticsOwnerId = `s-code-editor-diagnostics-${useId()}`
160
+ let scrollOwner: HTMLElement | null = null
161
+ let lastSelection: SCodeEditorSelectionRange | null = null
162
+ let lastViewState: SCodeEditorViewState | null = null
127
163
 
128
164
  const validatedModelValue = computed(() => {
129
165
  if (typeof props.modelValue === 'string') return props.modelValue
@@ -137,6 +173,11 @@ const validatedReadOnly = computed(() => validateBoolean('SCodeEditor', 'readOnl
137
173
  const validatedDisabled = computed(() => validateBoolean('SCodeEditor', 'disabled', props.disabled))
138
174
  const validatedInvalid = computed(() => validateBoolean('SCodeEditor', 'invalid', props.invalid))
139
175
  const isLocked = computed(() => validatedReadOnly.value || validatedDisabled.value)
176
+ const validatedDiagnostics = computed(() => validateCodeEditorDiagnostics(
177
+ 'SCodeEditor',
178
+ props.diagnostics,
179
+ validatedModelValue.value.length,
180
+ ))
140
181
 
141
182
  const resolvedLanguage = computed((): SCodeEditorLanguage => {
142
183
  const language = validateCodeEditorLanguage('SCodeEditor', props.language)
@@ -166,9 +207,12 @@ const contentAttributes = computed((): Record<string, string> => {
166
207
  } else {
167
208
  throw new TypeError('SCodeEditor: требуется ariaLabel или ariaLabelledby. / SCodeEditor: ariaLabel or ariaLabelledby is required.')
168
209
  }
169
- if (props.ariaDescribedby !== undefined) {
170
- attributes['aria-describedby'] = validateDomId('SCodeEditor', 'ariaDescribedby', props.ariaDescribedby)
171
- }
210
+ attributes.tabindex = validatedDisabled.value ? '-1' : '0'
211
+ const describedBy = props.ariaDescribedby === undefined
212
+ ? []
213
+ : [validateDomId('SCodeEditor', 'ariaDescribedby', props.ariaDescribedby)]
214
+ if (validatedDiagnostics.value.length > 0) describedBy.push(diagnosticsOwnerId)
215
+ if (describedBy.length > 0) attributes['aria-describedby'] = describedBy.join(' ')
172
216
  if (validatedInvalid.value) attributes['aria-invalid'] = 'true'
173
217
  if (validatedReadOnly.value) attributes['aria-readonly'] = 'true'
174
218
  if (validatedDisabled.value) attributes['aria-disabled'] = 'true'
@@ -197,6 +241,7 @@ const resolvedExtensions = computed<Extension[]>(() => {
197
241
  if (languageExtension) result.push(languageExtension)
198
242
  if (runtimeTheme.value.mode === 'dark') result.push(oneDark)
199
243
  if (capabilities.lineNumbers) result.push(lineNumbers())
244
+ if (validatedDiagnostics.value.length > 0) result.push(lintGutter())
200
245
  if (props.placeholder !== undefined) result.push(codeMirrorPlaceholder(validateNonEmptyString('SCodeEditor', 'placeholder', props.placeholder)))
201
246
  result.push(EditorState.readOnly.of(isLocked.value))
202
247
  result.push(EditorView.editable.of(!isLocked.value))
@@ -211,6 +256,7 @@ const resolvedExtensions = computed<Extension[]>(() => {
211
256
  }
212
257
  result.push(EditorView.updateListener.of((update) => {
213
258
  editorView.value = update.view
259
+ if (update.selectionSet || update.docChanged) emitSelectionAndViewState(update.view)
214
260
  if ((isLocked.value && capabilities.cursorActivity !== 'always') || (!update.selectionSet && !update.docChanged)) return
215
261
  const pos = update.state.selection.main.head
216
262
  const line = update.state.doc.lineAt(pos)
@@ -238,6 +284,93 @@ const dispatchSelection = (position: number, source: SCodeEditorSelectionApplied
238
284
  emit('selection-applied', { position: validated, source })
239
285
  }
240
286
 
287
+ const requireEditorView = (): EditorView => {
288
+ const view = editorView.value
289
+ if (!view) throw new Error('SCodeEditor: editor не готов; дождитесь ready. / SCodeEditor: editor is not ready; wait for ready.')
290
+ return view
291
+ }
292
+
293
+ const freezeSelection = (view: EditorView): SCodeEditorSelectionRange => {
294
+ const range = view.state.selection.main
295
+ return Object.freeze({
296
+ anchor: range.anchor,
297
+ head: range.head,
298
+ from: range.from,
299
+ to: range.to,
300
+ empty: range.empty,
301
+ })
302
+ }
303
+
304
+ const captureViewStateFrom = (view: EditorView): SCodeEditorViewState => Object.freeze({
305
+ selection: freezeSelection(view),
306
+ scrollTop: view.scrollDOM.scrollTop,
307
+ scrollLeft: view.scrollDOM.scrollLeft,
308
+ })
309
+
310
+ const selectionsEqual = (
311
+ left: SCodeEditorSelectionRange | null,
312
+ right: SCodeEditorSelectionRange,
313
+ ): boolean => left !== null
314
+ && left.anchor === right.anchor
315
+ && left.head === right.head
316
+ && left.from === right.from
317
+ && left.to === right.to
318
+ && left.empty === right.empty
319
+
320
+ const viewStatesEqual = (left: SCodeEditorViewState | null, right: SCodeEditorViewState): boolean =>
321
+ left !== null
322
+ && selectionsEqual(left.selection, right.selection)
323
+ && left.scrollTop === right.scrollTop
324
+ && left.scrollLeft === right.scrollLeft
325
+
326
+ const emitSelectionAndViewState = (view: EditorView): void => {
327
+ const selection = freezeSelection(view)
328
+ if (!selectionsEqual(lastSelection, selection)) {
329
+ lastSelection = selection
330
+ emit('selection-change', selection)
331
+ }
332
+ const viewState = captureViewStateFrom(view)
333
+ if (!viewStatesEqual(lastViewState, viewState)) {
334
+ lastViewState = viewState
335
+ emit('view-state-change', viewState)
336
+ }
337
+ }
338
+
339
+ const emitScrollViewState = (): void => {
340
+ const view = editorView.value
341
+ if (!view) return
342
+ const viewState = captureViewStateFrom(view)
343
+ if (viewStatesEqual(lastViewState, viewState)) return
344
+ lastViewState = viewState
345
+ emit('view-state-change', viewState)
346
+ }
347
+
348
+ const detachScrollOwner = (): void => {
349
+ scrollOwner?.removeEventListener('scroll', emitScrollViewState)
350
+ scrollOwner = null
351
+ }
352
+
353
+ const attachScrollOwner = (view: EditorView): void => {
354
+ detachScrollOwner()
355
+ scrollOwner = view.scrollDOM
356
+ scrollOwner.addEventListener('scroll', emitScrollViewState, { passive: true })
357
+ }
358
+
359
+ const toCodeMirrorDiagnostics = (
360
+ diagnostics: readonly SCodeEditorDiagnostic<SCodeEditorDiagnosticId>[],
361
+ ): readonly CodeMirrorDiagnostic[] => diagnostics.map((diagnostic) => ({
362
+ from: diagnostic.from,
363
+ to: diagnostic.to,
364
+ severity: diagnostic.severity,
365
+ message: diagnostic.message,
366
+ ...(diagnostic.source === undefined ? {} : { source: diagnostic.source }),
367
+ }))
368
+
369
+ const syncDiagnostics = (view: EditorView): void => {
370
+ const diagnostics = validateCodeEditorDiagnostics('SCodeEditor', props.diagnostics, view.state.doc.length)
371
+ view.dispatch(setDiagnostics(view.state, toCodeMirrorDiagnostics(diagnostics)))
372
+ }
373
+
241
374
  const onUpdate = (value: string): void => {
242
375
  if (typeof value !== 'string') {
243
376
  throw new TypeError('SCodeEditor: CodeMirror update payload должен быть строкой. / SCodeEditor: CodeMirror update payload must be a string.')
@@ -250,6 +383,8 @@ const onReady = (payload: unknown): void => {
250
383
  throw new TypeError('SCodeEditor: CodeMirror ready payload должен содержать EditorView. / SCodeEditor: CodeMirror ready payload must contain EditorView.')
251
384
  }
252
385
  editorView.value = (payload as { view: EditorView }).view
386
+ attachScrollOwner(editorView.value)
387
+ syncDiagnostics(editorView.value)
253
388
  const readyPayload: SCodeEditorReadyPayload = {
254
389
  documentLength: editorView.value.state.doc.length,
255
390
  viewport: validatedViewport.value,
@@ -276,11 +411,41 @@ watch(() => props.nextSelectionPos, (position) => {
276
411
  dispatchSelection(position, 'controlled')
277
412
  }, { immediate: true })
278
413
 
279
- defineExpose({
414
+ watch(validatedDiagnostics, () => {
415
+ if (editorView.value) syncDiagnostics(editorView.value)
416
+ })
417
+
418
+ onBeforeUnmount(() => {
419
+ detachScrollOwner()
420
+ editorView.value = null
421
+ lastSelection = null
422
+ lastViewState = null
423
+ })
424
+
425
+ defineExpose<SCodeEditorApi>({
280
426
  applySelection: (position: number) => dispatchSelection(position, 'imperative'),
427
+ revealRange: (range: SCodeEditorRange) => {
428
+ const view = requireEditorView()
429
+ const validated = validateCodeEditorRange('SCodeEditor', 'revealRange', range, view.state.doc.length)
430
+ const effect = EditorView.scrollIntoView(EditorSelection.range(validated.from, validated.to), { y: 'nearest' })
431
+ view.dispatch({ effects: effect })
432
+ },
433
+ getSelection: (): SCodeEditorSelectionSnapshot => {
434
+ const view = requireEditorView()
435
+ const selection = freezeSelection(view)
436
+ return Object.freeze({ ...selection, text: view.state.doc.sliceString(selection.from, selection.to) })
437
+ },
438
+ captureViewState: (): SCodeEditorViewState => captureViewStateFrom(requireEditorView()),
439
+ applyViewState: (viewState: SCodeEditorViewState) => {
440
+ const view = requireEditorView()
441
+ const validated = validateCodeEditorViewState('SCodeEditor', viewState, view.state.doc.length)
442
+ view.dispatch({ selection: EditorSelection.range(validated.selection.anchor, validated.selection.head) })
443
+ view.scrollDOM.scrollTop = validated.scrollTop
444
+ view.scrollDOM.scrollLeft = validated.scrollLeft
445
+ emitSelectionAndViewState(view)
446
+ },
281
447
  focus: () => {
282
- if (!editorView.value) throw new Error('SCodeEditor: editor не готов; дождитесь ready. / SCodeEditor: editor is not ready; wait for ready.')
283
- editorView.value.focus()
448
+ requireEditorView().focus()
284
449
  },
285
450
  })
286
451
  </script>