@aleph-alpha/chat-kit 6.3.0 → 6.4.1
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/dist/{CkHistory.vue_vue_type_script_setup_true_lang-gV-lp0ZB.js → CkHistory.vue_vue_type_script_setup_true_lang-BvRIp3m6.js} +79 -16
- package/dist/adapters/responses-api.js +1 -1
- package/dist/chat-kit.css +81 -11
- package/dist/{code-palette-DAJUTnR-.js → code-palette-BWrZDi47.js} +156 -119
- package/dist/components/base/CkCodeEditor/CkCodeEditor.stories.d.ts +8 -1
- package/dist/components/base/CkCodeEditor/CkCodeEditor.stories.d.ts.map +1 -1
- package/dist/components/base/CkCodeEditor/CkCodeEditor.vue.d.ts +2 -0
- package/dist/components/base/CkCodeEditor/CkCodeEditor.vue.d.ts.map +1 -1
- package/dist/components/base/CkCodeEditor/badgeDecoration.d.ts +47 -0
- package/dist/components/base/CkCodeEditor/badgeDecoration.d.ts.map +1 -0
- package/dist/components/base/CkCodeEditor/createCodeMirror.d.ts +19 -1
- package/dist/components/base/CkCodeEditor/createCodeMirror.d.ts.map +1 -1
- package/dist/components/base/CkCodeEditor/types.d.ts +32 -0
- package/dist/components/base/CkCodeEditor/types.d.ts.map +1 -1
- package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts +7 -0
- package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts.map +1 -1
- package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue.d.ts +9 -0
- package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue.d.ts.map +1 -1
- package/dist/components/base/CkMarkdownRenderer/types.d.ts +13 -0
- package/dist/components/base/CkMarkdownRenderer/types.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/composables/index.js +1 -1
- package/dist/createCodeMirror-BO-TWTzn.js +187 -0
- package/dist/{createCrepeEditor-Ci5FHHIV.js → createCrepeEditor-BGSlwmKn.js} +4 -4
- package/dist/handlers/index.js +2 -2
- package/dist/helpers/codemirror/theme.d.ts.map +1 -1
- package/dist/helpers/unified/remarkBreaks.d.ts +15 -0
- package/dist/helpers/unified/remarkBreaks.d.ts.map +1 -0
- package/dist/{index-w2yzH7vs.js → index--Yonjzfi.js} +3 -3
- package/dist/{index-CN8d3yl1.js → index-2YPxXIaf.js} +1 -1
- package/dist/{index-BoUJGvvQ.js → index-B4uqe-fL.js} +2 -2
- package/dist/{index-gYt3E3Cp.js → index-Bfo4U-Al.js} +2 -2
- package/dist/{index-BtY1uxn6.js → index-Bw39pbRL.js} +3 -3
- package/dist/{index-Dl24CCB3.js → index-CGEgwupi.js} +4 -4
- package/dist/{index-MfXW4ZyN.js → index-CZmdzLaU.js} +3 -3
- package/dist/{index-DgCcFC3N.js → index-CeaT_FBl.js} +3 -3
- package/dist/{index-BhvbqYsK.js → index-CefLTKmi.js} +2 -2
- package/dist/{index-CLdXUpL8.js → index-CfVHzirA.js} +5 -5
- package/dist/{index-BUCqcTPA.js → index-CrBanggA.js} +2 -2
- package/dist/{index-DufAP1JF.js → index-Cx5ThXzs.js} +3 -3
- package/dist/{index-CZFx44C7.js → index-D0DQjdsq.js} +2 -2
- package/dist/{index-DqGDenO9.js → index-DH9e_hqJ.js} +2 -2
- package/dist/{index-BvG6l44n.js → index-DTomeh-p.js} +3 -3
- package/dist/{index-ZHg2R9iD.js → index-DZGfsazg.js} +3 -3
- package/dist/{index-BLVAx_f8.js → index-DlLnTKv3.js} +2 -2
- package/dist/{index-Cxs6kPho.js → index-DqUiBDRo.js} +3 -3
- package/dist/{index-DhwsowEf.js → index-GDUM7K5V.js} +2 -2
- package/dist/{index-CB6xZVsf.js → index-Hro6EPZS.js} +2 -2
- package/dist/{index-D6ftdiLa.js → index-KgumaL1c.js} +1 -1
- package/dist/{index-CIF93PZF.js → index-TemHfU1M.js} +3 -3
- package/dist/{index-CYk7AB8M.js → index-g_Bs6dNr.js} +4 -4
- package/dist/{index-B2tew8uf.js → index-oO_X5wU0.js} +3 -3
- package/dist/{index-DxTzaHLr.js → index-ptbBQ53d.js} +4 -4
- package/dist/index.js +5 -5
- package/dist/{markdownToPlainText-BOtlQ_BY.js → markdownToPlainText-DN_G-I6g.js} +1 -1
- package/dist/{useChatKitLabels-CatQSpfL.js → useChatKitLabels-DxW0N5U-.js} +1 -1
- package/dist/{useMessageHandler-nnsort0-.js → useMessageHandler-Bkzrdgfj.js} +1 -1
- package/package.json +3 -3
- package/src/components/base/CkCodeEditor/CkCodeEditor.spec.ts +161 -1
- package/src/components/base/CkCodeEditor/CkCodeEditor.stories.ts +97 -1
- package/src/components/base/CkCodeEditor/CkCodeEditor.vue +135 -12
- package/src/components/base/CkCodeEditor/badgeDecoration.ts +173 -0
- package/src/components/base/CkCodeEditor/createCodeMirror.ts +54 -8
- package/src/components/base/CkCodeEditor/types.ts +38 -0
- package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.spec.ts +40 -0
- package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.ts +31 -0
- package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue +3 -0
- package/src/components/base/CkMarkdownRenderer/types.ts +13 -0
- package/src/components/base/CkMilkdownEditor/milkdown-theme.css +6 -3
- package/src/helpers/codemirror/code-palette.css +23 -3
- package/src/helpers/codemirror/theme.ts +44 -8
- package/src/helpers/unified/remarkBreaks.ts +32 -0
- package/dist/createCodeMirror-BkV6FEPn.js +0 -101
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import {
|
|
3
3
|
computed,
|
|
4
|
+
nextTick,
|
|
4
5
|
onBeforeUnmount,
|
|
5
6
|
onMounted,
|
|
6
7
|
ref,
|
|
7
8
|
useTemplateRef,
|
|
8
9
|
watch,
|
|
9
10
|
} from 'vue';
|
|
10
|
-
import { UiButton, UiIcon } from '@aleph-alpha/ui-library';
|
|
11
|
+
import { UiButton, UiFadeMask, UiIcon } from '@aleph-alpha/ui-library';
|
|
11
12
|
import { useChatKitLabels } from '../../../handlers/useChatKitLabels';
|
|
12
13
|
import type { CodeMirrorHandle } from './createCodeMirror';
|
|
13
14
|
import type { CkCodeEditorEmits, CkCodeEditorProps } from './types';
|
|
@@ -28,6 +29,8 @@ const props = withDefaults(defineProps<CkCodeEditorProps>(), {
|
|
|
28
29
|
lineWrapping: false,
|
|
29
30
|
maxHeight: undefined,
|
|
30
31
|
autofocus: false,
|
|
32
|
+
scrollFade: true,
|
|
33
|
+
badge: undefined,
|
|
31
34
|
writeToClipboard: undefined,
|
|
32
35
|
});
|
|
33
36
|
|
|
@@ -53,6 +56,63 @@ const copyLabel = computed(() =>
|
|
|
53
56
|
: (labels.value.codeEditorCopy ?? 'Copy code'),
|
|
54
57
|
);
|
|
55
58
|
|
|
59
|
+
// Whether there is code hidden above / below the visible area. Each fade is
|
|
60
|
+
// shown only for the direction that can actually scroll, so a document that
|
|
61
|
+
// fits its box shows none.
|
|
62
|
+
const canScrollUp = ref(false);
|
|
63
|
+
const canScrollDown = ref(false);
|
|
64
|
+
let scrollTarget: HTMLElement | undefined;
|
|
65
|
+
let scrollObserver: ResizeObserver | undefined;
|
|
66
|
+
|
|
67
|
+
// 53px is the band height the design file uses for this mask, rather than
|
|
68
|
+
// `UiFadeMask`'s own 4rem/6rem default, which is sized for page-level surfaces.
|
|
69
|
+
const FADE_HEIGHT = '53px';
|
|
70
|
+
|
|
71
|
+
function edgeToFade(up: boolean, down: boolean): 'top' | 'bottom' | 'both' {
|
|
72
|
+
if (up && down) return 'both';
|
|
73
|
+
return up ? 'top' : 'bottom';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// `UiFadeMask` takes the edges to draw rather than two independent flags, and
|
|
77
|
+
// has no "neither" position, so a document that fits collapses the band to zero
|
|
78
|
+
// height instead. Swapping the wrapper out for a plain `div` on every change
|
|
79
|
+
// would remount the host element and take CodeMirror's DOM with it, so the mask
|
|
80
|
+
// stays mounted for as long as `scrollFade` is on.
|
|
81
|
+
const fadeProps = computed(() => {
|
|
82
|
+
if (!props.scrollFade) return {};
|
|
83
|
+
const up = canScrollUp.value;
|
|
84
|
+
const down = canScrollDown.value;
|
|
85
|
+
return {
|
|
86
|
+
position: edgeToFade(up, down),
|
|
87
|
+
background: 'secondary',
|
|
88
|
+
style: { '--ui-fade-mask-height': up || down ? FADE_HEIGHT : '0px' },
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
function syncScrollFades(): void {
|
|
93
|
+
if (!scrollTarget) return;
|
|
94
|
+
const { scrollTop, scrollHeight, clientHeight } = scrollTarget;
|
|
95
|
+
canScrollUp.value = scrollTop > 1;
|
|
96
|
+
// A pixel of slack: fractional layout sizes otherwise leave the bottom fade
|
|
97
|
+
// showing when the document is scrolled fully down.
|
|
98
|
+
canScrollDown.value = scrollTop + clientHeight < scrollHeight - 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function watchScrolling(scroller: HTMLElement, content: HTMLElement): void {
|
|
102
|
+
scrollTarget = scroller;
|
|
103
|
+
scroller.addEventListener('scroll', syncScrollFades, { passive: true });
|
|
104
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
105
|
+
// Neither of these fires a scroll event. The scroller covers the box being
|
|
106
|
+
// resized; the content is watched separately because once `maxHeight` caps
|
|
107
|
+
// the scroller, typing a new line grows only the content, and the bottom
|
|
108
|
+
// fade would otherwise never appear.
|
|
109
|
+
scrollObserver = new ResizeObserver(syncScrollFades);
|
|
110
|
+
scrollObserver.observe(scroller);
|
|
111
|
+
scrollObserver.observe(content);
|
|
112
|
+
}
|
|
113
|
+
syncScrollFades();
|
|
114
|
+
}
|
|
115
|
+
|
|
56
116
|
let editor: CodeMirrorHandle | undefined;
|
|
57
117
|
let destroyed = false;
|
|
58
118
|
// Last value emitted or applied, so an echo from the parent is not re-applied.
|
|
@@ -84,9 +144,9 @@ onMounted(async () => {
|
|
|
84
144
|
readonly: isLocked.value,
|
|
85
145
|
showLineNumbers: props.showLineNumbers,
|
|
86
146
|
lineWrapping: props.lineWrapping,
|
|
147
|
+
badge: props.badge,
|
|
87
148
|
onChange: handleChange,
|
|
88
149
|
onFocus: () => emit('focus'),
|
|
89
|
-
onBlur: () => emit('blur'),
|
|
90
150
|
});
|
|
91
151
|
|
|
92
152
|
// Unmounted while the grammar was loading.
|
|
@@ -98,6 +158,7 @@ onMounted(async () => {
|
|
|
98
158
|
editor = handle;
|
|
99
159
|
// Props may have moved on while the grammar loaded; catch up.
|
|
100
160
|
if (props.modelValue !== initialCode) handle.setCode(props.modelValue);
|
|
161
|
+
watchScrolling(handle.view.scrollDOM, handle.view.contentDOM);
|
|
101
162
|
isReady.value = true;
|
|
102
163
|
if (props.autofocus) handle.focus();
|
|
103
164
|
});
|
|
@@ -108,6 +169,7 @@ watch(
|
|
|
108
169
|
if (next === lastKnownCode) return;
|
|
109
170
|
lastKnownCode = next;
|
|
110
171
|
editor?.setCode(next);
|
|
172
|
+
void nextTick(syncScrollFades);
|
|
111
173
|
},
|
|
112
174
|
);
|
|
113
175
|
|
|
@@ -128,10 +190,17 @@ watch(
|
|
|
128
190
|
() => props.lineWrapping,
|
|
129
191
|
(wrap) => editor?.setLineWrapping(wrap),
|
|
130
192
|
);
|
|
193
|
+
watch(
|
|
194
|
+
() => props.badge,
|
|
195
|
+
(rule) => editor?.setBadge(rule),
|
|
196
|
+
);
|
|
131
197
|
|
|
132
198
|
onBeforeUnmount(() => {
|
|
133
199
|
destroyed = true;
|
|
134
200
|
clearTimeout(copyTimer);
|
|
201
|
+
scrollTarget?.removeEventListener('scroll', syncScrollFades);
|
|
202
|
+
scrollObserver?.disconnect();
|
|
203
|
+
scrollTarget = undefined;
|
|
135
204
|
editor?.destroy();
|
|
136
205
|
editor = undefined;
|
|
137
206
|
});
|
|
@@ -157,8 +226,9 @@ function isInsideRoot(target: EventTarget | null): boolean {
|
|
|
157
226
|
return target instanceof Node && (rootRef.value?.contains(target) ?? false);
|
|
158
227
|
}
|
|
159
228
|
|
|
160
|
-
// CodeMirror reports focus on its own
|
|
161
|
-
//
|
|
229
|
+
// The single source of `blur`. CodeMirror reports focus loss on its own
|
|
230
|
+
// content, which would fire for a move to the header's copy button; only a move
|
|
231
|
+
// out of the whole component counts, and only the root can see that.
|
|
162
232
|
function onFocusOut(event: FocusEvent): void {
|
|
163
233
|
if (!isInsideRoot(event.relatedTarget)) emit('blur');
|
|
164
234
|
}
|
|
@@ -178,7 +248,7 @@ defineExpose({ focus, getCode });
|
|
|
178
248
|
<div
|
|
179
249
|
ref="rootRef"
|
|
180
250
|
:class="[
|
|
181
|
-
'ck-code-editor ck-code-surface border-border-surface-default min-w-0 overflow-hidden rounded-
|
|
251
|
+
'ck-code-editor ck-code-surface border-border-surface-default bg-background-input-default min-w-0 overflow-hidden rounded-xl border',
|
|
182
252
|
isReady && 'ck-code-editor--ready',
|
|
183
253
|
isLocked && 'ck-code-editor--readonly',
|
|
184
254
|
disabled && 'ck-code-editor--disabled',
|
|
@@ -188,9 +258,9 @@ defineExpose({ focus, getCode });
|
|
|
188
258
|
>
|
|
189
259
|
<div
|
|
190
260
|
v-if="showHeader"
|
|
191
|
-
class="
|
|
261
|
+
class="bg-background-surface-muted-subtle flex h-9 items-center justify-between px-3"
|
|
192
262
|
>
|
|
193
|
-
<span class="text-content-on-surface-secondary
|
|
263
|
+
<span class="text-content-on-surface-secondary label-12-regular">
|
|
194
264
|
{{ headerLabel }}
|
|
195
265
|
</span>
|
|
196
266
|
<UiButton
|
|
@@ -204,15 +274,33 @@ defineExpose({ focus, getCode });
|
|
|
204
274
|
</UiButton>
|
|
205
275
|
</div>
|
|
206
276
|
|
|
207
|
-
<
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
277
|
+
<component
|
|
278
|
+
:is="scrollFade ? UiFadeMask : 'div'"
|
|
279
|
+
v-bind="fadeProps"
|
|
280
|
+
class="ck-code-editor__body min-w-0"
|
|
281
|
+
>
|
|
282
|
+
<div
|
|
283
|
+
ref="hostRef"
|
|
284
|
+
class="ck-code-editor__host"
|
|
285
|
+
:style="maxHeight ? { '--ck-code-max-height': maxHeight } : undefined"
|
|
286
|
+
/>
|
|
287
|
+
</component>
|
|
212
288
|
</div>
|
|
213
289
|
</template>
|
|
214
290
|
|
|
215
291
|
<style scoped>
|
|
292
|
+
/* The body follows the design-system field rather than `UiCodeBlock`: body
|
|
293
|
+
* text in the primary content colour on the input surface, with muted mono
|
|
294
|
+
* line numbers. Only the syntax colours stay shared with `UiCodeBlock` (see
|
|
295
|
+
* `helpers/codemirror/code-palette.css`). The overrides live here, not in
|
|
296
|
+
* `theme.ts`, because `CkMilkdownEditor`'s code blocks use that theme too and
|
|
297
|
+
* must keep matching the rendered block. */
|
|
298
|
+
.ck-code-editor {
|
|
299
|
+
--ck-cm-bg: transparent;
|
|
300
|
+
--ck-cm-foreground: oklch(var(--content-on-surface-primary));
|
|
301
|
+
--ck-cm-line-number: oklch(var(--content-on-surface-muted));
|
|
302
|
+
}
|
|
303
|
+
|
|
216
304
|
/* The editor fills the panel; `:deep` is needed because CodeMirror builds its
|
|
217
305
|
* own DOM, which scoped styles do not reach. */
|
|
218
306
|
.ck-code-editor__host :deep(.cm-editor) {
|
|
@@ -220,8 +308,43 @@ defineExpose({ focus, getCode });
|
|
|
220
308
|
color: var(--ck-cm-foreground);
|
|
221
309
|
}
|
|
222
310
|
|
|
311
|
+
/* Same declarations as the `body-14-regular` typography utility. */
|
|
223
312
|
.ck-code-editor__host :deep(.cm-scroller) {
|
|
224
313
|
max-height: var(--ck-code-max-height, none);
|
|
314
|
+
font-family: Saans, sans-serif;
|
|
315
|
+
font-size: 0.875rem;
|
|
316
|
+
font-weight: 400;
|
|
317
|
+
line-height: 1.25rem;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* 12px below the header, 16px above the bottom edge, and a 20px right inset,
|
|
321
|
+
* as in the design. */
|
|
322
|
+
.ck-code-editor__host :deep(.cm-content) {
|
|
323
|
+
padding: 0.75rem 1.25rem 1rem 1rem;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/* The number column already carries the 1rem left inset, so the code must not
|
|
327
|
+
* pay for it a second time. The sibling selector only matches while the gutter
|
|
328
|
+
* is mounted, which is exactly when `showLineNumbers` is on. */
|
|
329
|
+
.ck-code-editor__host :deep(.cm-gutters ~ .cm-content) {
|
|
330
|
+
padding-left: 0;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/* Numbers stay monospaced so the column does not jitter as digits change; the
|
|
334
|
+
* 12px right padding is the gap before the code. */
|
|
335
|
+
.ck-code-editor__host :deep(.cm-lineNumbers .cm-gutterElement) {
|
|
336
|
+
min-width: 0;
|
|
337
|
+
padding: 0 0.75rem 0 1rem;
|
|
338
|
+
/* `CODE_FONT_FAMILY` from `theme.ts`, repeated because importing it here
|
|
339
|
+
* would pull CodeMirror out of its lazy chunk. */
|
|
340
|
+
font-family:
|
|
341
|
+
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono',
|
|
342
|
+
monospace;
|
|
343
|
+
font-weight: 500;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
.ck-code-editor__host :deep(.cm-placeholder) {
|
|
347
|
+
color: oklch(var(--content-on-surface-muted));
|
|
225
348
|
}
|
|
226
349
|
|
|
227
350
|
/* Nothing is being edited, so the active-line band would only be noise. */
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { Extension, Range } from '@codemirror/state';
|
|
2
|
+
import {
|
|
3
|
+
Decoration,
|
|
4
|
+
type DecorationSet,
|
|
5
|
+
EditorView,
|
|
6
|
+
MatchDecorator,
|
|
7
|
+
ViewPlugin,
|
|
8
|
+
type ViewUpdate,
|
|
9
|
+
WidgetType,
|
|
10
|
+
} from '@codemirror/view';
|
|
11
|
+
import DOMPurify from 'dompurify';
|
|
12
|
+
|
|
13
|
+
/** What a matched reference should look like once it is drawn as a pill. */
|
|
14
|
+
export interface CkCodeEditorBadgeContent {
|
|
15
|
+
/** Text inside the pill. */
|
|
16
|
+
label: string;
|
|
17
|
+
/**
|
|
18
|
+
* Optional inline SVG markup for a leading icon, sized to 12px. This is
|
|
19
|
+
* developer-supplied configuration rather than document content, and it is
|
|
20
|
+
* sanitised before it reaches the DOM.
|
|
21
|
+
*/
|
|
22
|
+
icon?: string;
|
|
23
|
+
/** Native tooltip, useful for showing the underlying reference. */
|
|
24
|
+
title?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Classes replacing the default pill styling, which is the secondary badge
|
|
27
|
+
* the ChatKit design file specifies.
|
|
28
|
+
*/
|
|
29
|
+
class?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Rule turning matched text in the document into inline pills. */
|
|
33
|
+
export interface CkCodeEditorBadgeRule {
|
|
34
|
+
/**
|
|
35
|
+
* Pattern matched within each line. A non-global pattern is promoted to a
|
|
36
|
+
* global one, and matching never crosses a line break.
|
|
37
|
+
*/
|
|
38
|
+
pattern: RegExp;
|
|
39
|
+
/**
|
|
40
|
+
* Maps a match to its pill, or returns `null` to leave that occurrence as
|
|
41
|
+
* ordinary text. The match array carries any capture groups.
|
|
42
|
+
*/
|
|
43
|
+
render: (match: RegExpExecArray) => CkCodeEditorBadgeContent | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The badge as the ChatKit design file specifies it: the shadcn `secondary`
|
|
47
|
+
// badge at a 6px radius, 8px/2px padding, a 4px gap and a 12px label. Written
|
|
48
|
+
// out because a widget builds DOM imperatively and cannot mount a Vue component
|
|
49
|
+
// cheaply; these class names sit in chat-kit source, so UnoCSS emits them into
|
|
50
|
+
// the package stylesheet like any other.
|
|
51
|
+
const DEFAULT_BADGE_CLASS =
|
|
52
|
+
'ck-code-badge inline-flex w-fit shrink-0 select-none items-center justify-center gap-1 ' +
|
|
53
|
+
'overflow-hidden whitespace-nowrap rounded-md border border-border-surface-default ' +
|
|
54
|
+
'bg-background-button-secondary-button text-content-on-surface-primary px-2 py-0.5 ' +
|
|
55
|
+
'label-12-regular align-middle';
|
|
56
|
+
|
|
57
|
+
class BadgeWidget extends WidgetType {
|
|
58
|
+
constructor(private readonly content: CkCodeEditorBadgeContent) {
|
|
59
|
+
super();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Decorations are rebuilt on every document and viewport change. Without a
|
|
63
|
+
// real comparison here every pill would be torn down and recreated on each
|
|
64
|
+
// keystroke, which flickers and drops any focus inside the widget.
|
|
65
|
+
override eq(other: BadgeWidget): boolean {
|
|
66
|
+
return (
|
|
67
|
+
other.content.label === this.content.label &&
|
|
68
|
+
other.content.icon === this.content.icon &&
|
|
69
|
+
other.content.title === this.content.title &&
|
|
70
|
+
other.content.class === this.content.class
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
toDOM(): HTMLElement {
|
|
75
|
+
const el = document.createElement('span');
|
|
76
|
+
el.className = this.content.class ?? DEFAULT_BADGE_CLASS;
|
|
77
|
+
if (this.content.title) el.title = this.content.title;
|
|
78
|
+
|
|
79
|
+
if (this.content.icon) {
|
|
80
|
+
const icon = document.createElement('span');
|
|
81
|
+
icon.className = 'ck-code-badge__icon inline-flex shrink-0';
|
|
82
|
+
// Config, not document content — but sanitised anyway, since it is the
|
|
83
|
+
// only markup in this file that does not originate here.
|
|
84
|
+
icon.innerHTML = DOMPurify.sanitize(this.content.icon, {
|
|
85
|
+
USE_PROFILES: { svg: true, svgFilters: true },
|
|
86
|
+
});
|
|
87
|
+
el.append(icon);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
el.append(document.createTextNode(this.content.label));
|
|
91
|
+
return el;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The pill is decorative; let the editor handle every event over it. */
|
|
95
|
+
override ignoreEvent(): boolean {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** `MatchDecorator` requires a global pattern and mutates `lastIndex`. */
|
|
101
|
+
function toGlobal(pattern: RegExp): RegExp {
|
|
102
|
+
const flags = pattern.flags.includes('g')
|
|
103
|
+
? pattern.flags
|
|
104
|
+
: `${pattern.flags}g`;
|
|
105
|
+
return new RegExp(pattern.source, flags);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Draw matched references as inline pills, the way a mention or a chip renders
|
|
110
|
+
* inside prose.
|
|
111
|
+
*
|
|
112
|
+
* The document keeps the original text: the decoration only replaces what is
|
|
113
|
+
* *drawn*, so reading the value, copying and pasting all still yield the raw
|
|
114
|
+
* reference. The matched range is also registered as atomic, so the caret
|
|
115
|
+
* treats a pill as a single unit and backspace removes the whole reference
|
|
116
|
+
* rather than one character of it.
|
|
117
|
+
*/
|
|
118
|
+
export function badgeExtension(rule: CkCodeEditorBadgeRule): Extension {
|
|
119
|
+
const matcher = new MatchDecorator({
|
|
120
|
+
regexp: toGlobal(rule.pattern),
|
|
121
|
+
decoration: (match) => {
|
|
122
|
+
const content = rule.render(match as RegExpExecArray);
|
|
123
|
+
return content
|
|
124
|
+
? Decoration.replace({ widget: new BadgeWidget(content) })
|
|
125
|
+
: null;
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return ViewPlugin.fromClass(
|
|
130
|
+
class {
|
|
131
|
+
badges: DecorationSet;
|
|
132
|
+
|
|
133
|
+
constructor(view: EditorView) {
|
|
134
|
+
this.badges = matcher.createDeco(view);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
update(update: ViewUpdate): void {
|
|
138
|
+
this.badges = matcher.updateDeco(update, this.badges);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
decorations: (plugin) => plugin.badges,
|
|
143
|
+
provide: (plugin) =>
|
|
144
|
+
EditorView.atomicRanges.of(
|
|
145
|
+
(view) => view.plugin(plugin)?.badges ?? Decoration.none,
|
|
146
|
+
),
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Exported for tests: the ranges a rule would decorate in a plain string. */
|
|
152
|
+
export function matchBadgeRanges(
|
|
153
|
+
rule: CkCodeEditorBadgeRule,
|
|
154
|
+
text: string,
|
|
155
|
+
): Range<Decoration>[] {
|
|
156
|
+
const pattern = toGlobal(rule.pattern);
|
|
157
|
+
const ranges: Range<Decoration>[] = [];
|
|
158
|
+
let match: RegExpExecArray | null;
|
|
159
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
160
|
+
const content = rule.render(match);
|
|
161
|
+
if (content) {
|
|
162
|
+
ranges.push(
|
|
163
|
+
Decoration.replace({ widget: new BadgeWidget(content) }).range(
|
|
164
|
+
match.index,
|
|
165
|
+
match.index + match[0].length,
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
// A zero-length match would spin forever otherwise.
|
|
170
|
+
if (match[0] === '') pattern.lastIndex += 1;
|
|
171
|
+
}
|
|
172
|
+
return ranges;
|
|
173
|
+
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
placeholder as placeholderExt,
|
|
23
23
|
} from '@codemirror/view';
|
|
24
24
|
import { ckCodeMirrorTheme } from '../../../helpers/codemirror/theme';
|
|
25
|
+
import { badgeExtension, type CkCodeEditorBadgeRule } from './badgeDecoration';
|
|
25
26
|
import '../../../helpers/codemirror/code-palette.css';
|
|
26
27
|
|
|
27
28
|
export interface CreateCodeMirrorOptions {
|
|
@@ -34,10 +35,16 @@ export interface CreateCodeMirrorOptions {
|
|
|
34
35
|
readonly: boolean;
|
|
35
36
|
showLineNumbers: boolean;
|
|
36
37
|
lineWrapping: boolean;
|
|
38
|
+
/** Draws matched references as inline pills; see `badgeDecoration.ts`. */
|
|
39
|
+
badge?: CkCodeEditorBadgeRule;
|
|
37
40
|
/** Called synchronously whenever the document changes. */
|
|
38
41
|
onChange: (value: string) => void;
|
|
42
|
+
/**
|
|
43
|
+
* Called when the editing area takes focus. There is deliberately no `onBlur`
|
|
44
|
+
* counterpart: losing focus to the header's copy button is not leaving the
|
|
45
|
+
* editor, and only the component around this view can tell the difference.
|
|
46
|
+
*/
|
|
39
47
|
onFocus: () => void;
|
|
40
|
-
onBlur: () => void;
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
/** Imperative surface `CkCodeEditor` drives; keeps CodeMirror out of the SFC. */
|
|
@@ -50,6 +57,7 @@ export interface CodeMirrorHandle {
|
|
|
50
57
|
setReadonly: (readonly: boolean) => void;
|
|
51
58
|
setShowLineNumbers: (show: boolean) => void;
|
|
52
59
|
setLineWrapping: (wrap: boolean) => void;
|
|
60
|
+
setBadge: (rule: CkCodeEditorBadgeRule | undefined) => void;
|
|
53
61
|
focus: () => void;
|
|
54
62
|
destroy: () => void;
|
|
55
63
|
}
|
|
@@ -85,6 +93,22 @@ export async function resolveLanguage(
|
|
|
85
93
|
}
|
|
86
94
|
}
|
|
87
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Pick the line ending a document already uses.
|
|
98
|
+
*
|
|
99
|
+
* CodeMirror splits on `\n`, `\r\n` and `\r` alike, but `doc.toString()` always
|
|
100
|
+
* joins with `\n`. Left alone, opening a file with Windows line endings and
|
|
101
|
+
* typing one character would silently rewrite every line ending in it. Telling
|
|
102
|
+
* the editor which separator this document uses keeps it intact, both when
|
|
103
|
+
* reading the value back and when the user presses Enter.
|
|
104
|
+
*/
|
|
105
|
+
export function detectLineSeparator(doc: string): string {
|
|
106
|
+
const crlf = (doc.match(/\r\n/g) ?? []).length;
|
|
107
|
+
if (crlf === 0) return '\n';
|
|
108
|
+
const bareLf = (doc.match(/(?<!\r)\n/g) ?? []).length;
|
|
109
|
+
return crlf >= bareLf ? '\r\n' : '\n';
|
|
110
|
+
}
|
|
111
|
+
|
|
88
112
|
// `readOnly` rejects edits while keeping the text selectable; `editable`
|
|
89
113
|
// additionally drops `contenteditable`, which hides the caret and keeps the
|
|
90
114
|
// editor out of the tab order.
|
|
@@ -92,6 +116,11 @@ function readonlyState(readonly: boolean): Extension {
|
|
|
92
116
|
return [EditorState.readOnly.of(readonly), EditorView.editable.of(!readonly)];
|
|
93
117
|
}
|
|
94
118
|
|
|
119
|
+
/** The document as text, with the line endings it came in with. */
|
|
120
|
+
function readDoc(state: EditorState): string {
|
|
121
|
+
return state.doc.sliceString(0, state.doc.length, state.lineBreak);
|
|
122
|
+
}
|
|
123
|
+
|
|
95
124
|
function baseExtensions(options: CreateCodeMirrorOptions): Extension[] {
|
|
96
125
|
return [
|
|
97
126
|
// Deliberately not CodeMirror's `basicSetup`: it also brings autocomplete,
|
|
@@ -112,11 +141,8 @@ function baseExtensions(options: CreateCodeMirrorOptions): Extension[] {
|
|
|
112
141
|
...(options.ariaLabel ? { 'aria-label': options.ariaLabel } : {}),
|
|
113
142
|
}),
|
|
114
143
|
EditorView.updateListener.of((update) => {
|
|
115
|
-
if (update.docChanged) options.onChange(update.state
|
|
116
|
-
if (update.focusChanged)
|
|
117
|
-
if (update.view.hasFocus) options.onFocus();
|
|
118
|
-
else options.onBlur();
|
|
119
|
-
}
|
|
144
|
+
if (update.docChanged) options.onChange(readDoc(update.state));
|
|
145
|
+
if (update.focusChanged && update.view.hasFocus) options.onFocus();
|
|
120
146
|
}),
|
|
121
147
|
...(options.placeholder ? [placeholderExt(options.placeholder)] : []),
|
|
122
148
|
];
|
|
@@ -135,8 +161,12 @@ export async function createCodeMirror(
|
|
|
135
161
|
const readonlySlot = new Compartment();
|
|
136
162
|
const lineNumbersSlot = new Compartment();
|
|
137
163
|
const wrappingSlot = new Compartment();
|
|
164
|
+
const badgeSlot = new Compartment();
|
|
165
|
+
const lineSeparatorSlot = new Compartment();
|
|
138
166
|
|
|
139
167
|
const support = await resolveLanguage(options.language);
|
|
168
|
+
// Guards against out-of-order grammar loads; see `setLanguage` below.
|
|
169
|
+
let languageRequestId = 0;
|
|
140
170
|
|
|
141
171
|
const view = new EditorView({
|
|
142
172
|
parent: options.parent,
|
|
@@ -148,6 +178,10 @@ export async function createCodeMirror(
|
|
|
148
178
|
readonlySlot.of(readonlyState(options.readonly)),
|
|
149
179
|
lineNumbersSlot.of(options.showLineNumbers ? lineNumbers() : []),
|
|
150
180
|
wrappingSlot.of(options.lineWrapping ? EditorView.lineWrapping : []),
|
|
181
|
+
badgeSlot.of(options.badge ? badgeExtension(options.badge) : []),
|
|
182
|
+
lineSeparatorSlot.of(
|
|
183
|
+
EditorState.lineSeparator.of(detectLineSeparator(options.doc)),
|
|
184
|
+
),
|
|
151
185
|
],
|
|
152
186
|
}),
|
|
153
187
|
});
|
|
@@ -158,9 +192,14 @@ export async function createCodeMirror(
|
|
|
158
192
|
|
|
159
193
|
return {
|
|
160
194
|
view,
|
|
161
|
-
getCode: () => view.state
|
|
195
|
+
getCode: () => readDoc(view.state),
|
|
162
196
|
setCode: (value) => {
|
|
163
|
-
if (value === view.state
|
|
197
|
+
if (value === readDoc(view.state)) return;
|
|
198
|
+
// New content may use different line endings from what is loaded.
|
|
199
|
+
const separator = detectLineSeparator(value);
|
|
200
|
+
if (separator !== view.state.lineBreak) {
|
|
201
|
+
reconfigure(lineSeparatorSlot, EditorState.lineSeparator.of(separator));
|
|
202
|
+
}
|
|
164
203
|
// Replacing the whole document would otherwise send the cursor home
|
|
165
204
|
// whenever a parent echoes the value back; clamp it to the new length.
|
|
166
205
|
const cursor = Math.min(view.state.selection.main.head, value.length);
|
|
@@ -170,7 +209,11 @@ export async function createCodeMirror(
|
|
|
170
209
|
});
|
|
171
210
|
},
|
|
172
211
|
setLanguage: async (language) => {
|
|
212
|
+
// Grammars load over the network and out of order, so a slow one must not
|
|
213
|
+
// land on top of a language chosen after it.
|
|
214
|
+
const request = ++languageRequestId;
|
|
173
215
|
const next = await resolveLanguage(language);
|
|
216
|
+
if (request !== languageRequestId) return;
|
|
174
217
|
reconfigure(languageSlot, next ? [next] : []);
|
|
175
218
|
},
|
|
176
219
|
setReadonly: (readonly) => {
|
|
@@ -182,6 +225,9 @@ export async function createCodeMirror(
|
|
|
182
225
|
setLineWrapping: (wrap) => {
|
|
183
226
|
reconfigure(wrappingSlot, wrap ? EditorView.lineWrapping : []);
|
|
184
227
|
},
|
|
228
|
+
setBadge: (rule) => {
|
|
229
|
+
reconfigure(badgeSlot, rule ? badgeExtension(rule) : []);
|
|
230
|
+
},
|
|
185
231
|
focus: () => view.focus(),
|
|
186
232
|
destroy: () => view.destroy(),
|
|
187
233
|
};
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import type { CkCodeEditorBadgeRule } from './badgeDecoration';
|
|
2
|
+
|
|
3
|
+
// Re-exported so consumers get the badge types from the component's entry point.
|
|
4
|
+
export type {
|
|
5
|
+
CkCodeEditorBadgeContent,
|
|
6
|
+
CkCodeEditorBadgeRule,
|
|
7
|
+
} from './badgeDecoration';
|
|
8
|
+
|
|
1
9
|
/**
|
|
2
10
|
* Single-document code editor built on [CodeMirror 6](https://codemirror.net/).
|
|
3
11
|
* Its surface is the one `UiCodeBlock` renders for fenced code inside
|
|
@@ -94,12 +102,42 @@ export interface CkCodeEditorProps {
|
|
|
94
102
|
* `'24rem'`. Unset, the editor grows with its content.
|
|
95
103
|
*/
|
|
96
104
|
maxHeight?: string;
|
|
105
|
+
/**
|
|
106
|
+
* Fade the code out at the top and bottom edges when there is more of it
|
|
107
|
+
* scrolled out of view, so a clipped document reads as continuing rather than
|
|
108
|
+
* stopping mid-line.
|
|
109
|
+
*
|
|
110
|
+
* Each edge appears only when that direction can actually scroll, so a
|
|
111
|
+
* document that fits shows no fade at all. Relevant mainly alongside
|
|
112
|
+
* `maxHeight`, which is what makes the editor scroll in the first place.
|
|
113
|
+
*
|
|
114
|
+
* @default true
|
|
115
|
+
*/
|
|
116
|
+
scrollFade?: boolean;
|
|
97
117
|
/**
|
|
98
118
|
* Move focus into the editor once it has been created.
|
|
99
119
|
*
|
|
100
120
|
* @default false
|
|
101
121
|
*/
|
|
102
122
|
autofocus?: boolean;
|
|
123
|
+
/**
|
|
124
|
+
* Draws matched text as an inline pill, the way a mention renders in prose.
|
|
125
|
+
* Give a pattern and a callback mapping each match to a label and optional
|
|
126
|
+
* icon; return `null` from the callback to leave an occurrence alone.
|
|
127
|
+
*
|
|
128
|
+
* The document keeps the original text, so `getCode()`, copying and pasting
|
|
129
|
+
* still yield the raw reference. A pill behaves as one unit for the caret,
|
|
130
|
+
* so backspace removes the whole reference.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* const badge = {
|
|
135
|
+
* pattern: /@collection:([\w-]+)/g,
|
|
136
|
+
* render: (m) => ({ label: m[1], icon: folderSvg }),
|
|
137
|
+
* };
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
badge?: CkCodeEditorBadgeRule;
|
|
103
141
|
/**
|
|
104
142
|
* Clipboard writer for the copy button. Injected for tests and for hosts that
|
|
105
143
|
* proxy clipboard access; defaults to `navigator.clipboard.writeText`.
|
|
@@ -470,3 +470,43 @@ describe('CkMarkdownRenderer', () => {
|
|
|
470
470
|
});
|
|
471
471
|
});
|
|
472
472
|
});
|
|
473
|
+
|
|
474
|
+
describe('breaks', () => {
|
|
475
|
+
it('collapses a single newline into one line by default', () => {
|
|
476
|
+
const { container } = render(CkMarkdownRenderer, {
|
|
477
|
+
props: { content: 'line one\nline two' },
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
expect(container.querySelectorAll('p')).toHaveLength(1);
|
|
481
|
+
expect(container.querySelector('br')).toBeNull();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('keeps a single newline as a line break when `breaks` is set', () => {
|
|
485
|
+
const { container } = render(CkMarkdownRenderer, {
|
|
486
|
+
props: { content: 'line one\nline two', breaks: true },
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
expect(container.querySelectorAll('p')).toHaveLength(1);
|
|
490
|
+
expect(container.querySelectorAll('br')).toHaveLength(1);
|
|
491
|
+
expect(container.querySelector('p')?.textContent).toContain('line one');
|
|
492
|
+
expect(container.querySelector('p')?.textContent).toContain('line two');
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('still starts a new paragraph on a blank line', () => {
|
|
496
|
+
const { container } = render(CkMarkdownRenderer, {
|
|
497
|
+
props: { content: 'one\n\ntwo', breaks: true },
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
expect(container.querySelectorAll('p')).toHaveLength(2);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('leaves newlines inside fenced code alone', () => {
|
|
504
|
+
const { container } = render(CkMarkdownRenderer, {
|
|
505
|
+
props: { content: '```\nfirst\nsecond\n```', breaks: true },
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const code = container.querySelector('pre code');
|
|
509
|
+
expect(code?.textContent).toBe('first\nsecond\n');
|
|
510
|
+
expect(code?.querySelector('br')).toBeNull();
|
|
511
|
+
});
|
|
512
|
+
});
|