@elabs-ai/components-editor 4.0.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -7
- package/dist/{chunk-LBC5VJBD.js → chunk-FO5S3YZM.js} +349 -177
- package/dist/chunk-FO5S3YZM.js.map +1 -0
- package/dist/index.css +4 -2
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.js +112 -57
- package/dist/index.js.map +1 -1
- package/dist/markdown/index.css +4 -2
- package/dist/markdown/index.css.map +1 -1
- package/dist/markdown/index.d.ts +2 -2
- package/dist/markdown/index.js +268 -191
- package/dist/markdown/index.js.map +1 -1
- package/dist/{markdown-editor-DfBZibAn.d.ts → markdown-editor-Dn-0L_MM.d.ts} +9 -2
- package/dist/monaco.d.ts +2 -0
- package/dist/monaco.js +9 -0
- package/dist/monaco.js.map +1 -0
- package/package.json +10 -6
- package/src/ai-objects/decision-card.tsx +15 -9
- package/src/ai-objects/entity.tsx +1 -1
- package/src/ai-objects/knowledge-card.tsx +18 -9
- package/src/barrel-monaco-lazy.test.ts +50 -0
- package/src/calc-block/calc-block.tsx +10 -4
- package/src/calc-block/calc-editor.css +1 -1
- package/src/code-editor/code-editor.stories.tsx +76 -1
- package/src/code-editor/code-editor.test.tsx +141 -14
- package/src/code-editor/code-editor.tsx +167 -36
- package/src/code-workspace/code-workspace.test.tsx +113 -11
- package/src/code-workspace/code-workspace.tsx +73 -20
- package/src/copy-button/copy-button.tsx +6 -3
- package/src/diff-editor/diff-editor.stories.tsx +9 -1
- package/src/diff-editor/diff-editor.test.tsx +9 -3
- package/src/diff-editor/diff-editor.tsx +47 -23
- package/src/editor-toolbar/editor-toolbar.tsx +8 -2
- package/src/index.ts +4 -3
- package/src/lib/monaco-deep-imports.d.ts +59 -0
- package/src/lib/monaco-theme-bridge.test.ts +307 -0
- package/src/lib/monaco-theme-bridge.ts +154 -10
- package/src/markdown-academic/citations.tsx +14 -7
- package/src/markdown-academic/footnotes.tsx +2 -2
- package/src/markdown-academic/math.tsx +7 -4
- package/src/markdown-academic/toc.tsx +1 -1
- package/src/markdown-editor/completions/completions-menu.tsx +5 -2
- package/src/markdown-editor/directive-views.tsx +35 -24
- package/src/markdown-editor/markdown-editor.css +26 -3
- package/src/markdown-editor/markdown-editor.focus.test.ts +49 -0
- package/src/markdown-editor/markdown-editor.stories.tsx +128 -7
- package/src/markdown-editor/markdown-editor.tsx +8 -5
- package/src/markdown-editor/milkdown-react/use-get-editor.timer-leak.test.ts +80 -0
- package/src/markdown-editor/milkdown-react/use-get-editor.ts +37 -1
- package/src/markdown-editor/paste-embed.ts +2 -1
- package/src/markdown-editor/slash/slash-menu.stories.tsx +8 -3
- package/src/markdown-editor/slash/slash-menu.test.tsx +27 -0
- package/src/markdown-editor/slash/slash-menu.tsx +18 -3
- package/src/markdown-editor/table-view.tsx +31 -17
- package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +57 -19
- package/src/markdown-iteration/iteration-builder-dialog.tsx +39 -18
- package/src/markdown-iteration/template-dialog.tsx +25 -7
- package/src/markdown-outline/document-outline.stories.tsx +1 -1
- package/src/markdown-outline/document-outline.tsx +7 -3
- package/src/markdown-preview/markdown-preview-academic.test.tsx +3 -3
- package/src/markdown-preview/markdown-preview-transclusion.test.tsx +1 -1
- package/src/markdown-preview/markdown-preview.stories.tsx +24 -2
- package/src/markdown-preview/markdown-preview.test.tsx +20 -3
- package/src/markdown-toolbar/markdown-toolbar.stories.tsx +3 -0
- package/src/markdown-toolbar/markdown-toolbar.tsx +42 -24
- package/src/markdown-workspace/markdown-workspace.test.tsx +53 -4
- package/src/markdown-workspace/markdown-workspace.tsx +6 -3
- package/src/mermaid-diagram/mermaid-diagram-fixes.test.tsx +130 -0
- package/src/mermaid-diagram/mermaid-diagram.test.tsx +2 -1
- package/src/mermaid-diagram/mermaid-diagram.tsx +89 -40
- package/src/mermaid-diagram/mermaid-viewer.tsx +22 -21
- package/src/monaco.ts +21 -0
- package/src/prose/prose.stories.tsx +3 -0
- package/src/prose/prose.test.ts +54 -0
- package/src/prose/prose.tsx +7 -0
- package/dist/chunk-LBC5VJBD.js.map +0 -1
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* a contained error, never crashes the page). a11y: `output: "htmlAndMathml"`
|
|
15
15
|
* emits MathML (read by assistive tech) alongside the visual HTML.
|
|
16
16
|
*/
|
|
17
|
+
import { useLocale } from "@elabs-ai/components-ui";
|
|
17
18
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
18
19
|
import katex from "katex";
|
|
19
20
|
import { useMemo, type HTMLAttributes } from "react";
|
|
@@ -96,13 +97,14 @@ export interface MathProps extends Omit<HTMLAttributes<HTMLElement>, "children">
|
|
|
96
97
|
|
|
97
98
|
/** Inline math (`$…$`) → KaTeX, in the text flow. */
|
|
98
99
|
export function MathInline({ tex, className, ...props }: MathProps) {
|
|
100
|
+
const { t } = useLocale();
|
|
99
101
|
const { html, error } = useMemo(() => renderKatex(tex, false), [tex]);
|
|
100
102
|
if (error) {
|
|
101
103
|
return (
|
|
102
104
|
<code
|
|
103
105
|
className={cn("text-destructive-text", className)}
|
|
104
|
-
aria-label={
|
|
105
|
-
title="
|
|
106
|
+
aria-label={t("editor.math.renderErrorLabel", { tex })}
|
|
107
|
+
title={t("editor.math.renderError")}
|
|
106
108
|
{...props}
|
|
107
109
|
>
|
|
108
110
|
{tex}
|
|
@@ -125,6 +127,7 @@ export function MathInline({ tex, className, ...props }: MathProps) {
|
|
|
125
127
|
|
|
126
128
|
/** Block math (`$$…$$`) → centered display KaTeX. */
|
|
127
129
|
export function MathBlock({ tex, className, ...props }: MathProps) {
|
|
130
|
+
const { t } = useLocale();
|
|
128
131
|
const { html, error } = useMemo(() => renderKatex(tex, true), [tex]);
|
|
129
132
|
if (error) {
|
|
130
133
|
return (
|
|
@@ -133,8 +136,8 @@ export function MathBlock({ tex, className, ...props }: MathProps) {
|
|
|
133
136
|
"overflow-x-auto rounded-md bg-surface-muted p-3 text-destructive-text",
|
|
134
137
|
className,
|
|
135
138
|
)}
|
|
136
|
-
aria-label={
|
|
137
|
-
title="
|
|
139
|
+
aria-label={t("editor.math.renderErrorLabel", { tex })}
|
|
140
|
+
title={t("editor.math.renderError")}
|
|
138
141
|
{...props}
|
|
139
142
|
>
|
|
140
143
|
<code>{tex}</code>
|
|
@@ -75,7 +75,7 @@ export const TableOfContents = forwardRef<HTMLElement, TableOfContentsProps>(
|
|
|
75
75
|
<li key={it.id} className={INDENT[Math.min(it.level - minLevel, INDENT.length - 1)]}>
|
|
76
76
|
<a
|
|
77
77
|
href={`#${it.id}`}
|
|
78
|
-
className="text-muted-foreground underline hover:text-foreground hover:underline focus-visible:rounded-sm focus-
|
|
78
|
+
className="text-muted-foreground underline hover:text-foreground hover:underline focus-visible:rounded-sm focus-ring"
|
|
79
79
|
>
|
|
80
80
|
{it.text}
|
|
81
81
|
</a>
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* Same visual grammar (bg-popover, accent selection) for consistency with the
|
|
9
9
|
* slash popup and Monaco's own themed suggest widget.
|
|
10
10
|
*/
|
|
11
|
+
import { useLocale } from "@elabs-ai/components-ui";
|
|
11
12
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
12
13
|
import { forwardRef, type HTMLAttributes } from "react";
|
|
13
14
|
|
|
@@ -38,17 +39,19 @@ export const CompletionMenu = forwardRef<HTMLDivElement, CompletionMenuProps>(
|
|
|
38
39
|
activeIndex,
|
|
39
40
|
onSelect,
|
|
40
41
|
idPrefix = "brand-completions",
|
|
41
|
-
emptyLabel
|
|
42
|
+
emptyLabel: emptyLabelProp,
|
|
42
43
|
className,
|
|
43
44
|
...props
|
|
44
45
|
},
|
|
45
46
|
ref,
|
|
46
47
|
) {
|
|
48
|
+
const { t } = useLocale();
|
|
49
|
+
const emptyLabel = emptyLabelProp ?? t("editor.completions.noSuggestions");
|
|
47
50
|
return (
|
|
48
51
|
<div
|
|
49
52
|
ref={ref}
|
|
50
53
|
role="listbox"
|
|
51
|
-
aria-label="
|
|
54
|
+
aria-label={t("editor.completions.suggestions")}
|
|
52
55
|
className={cn(
|
|
53
56
|
"max-h-[min(280px,50vh)] w-64 overflow-y-auto overflow-x-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-ring-md",
|
|
54
57
|
className,
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
DropdownMenuSubContent,
|
|
47
47
|
DropdownMenuSubTrigger,
|
|
48
48
|
DropdownMenuTrigger,
|
|
49
|
+
useLocale,
|
|
49
50
|
} from "@elabs-ai/components-ui";
|
|
50
51
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
51
52
|
import { editorViewCtx, parserCtx, serializerCtx } from "@milkdown/kit/core";
|
|
@@ -170,16 +171,14 @@ function InlineEdit({ value, onCommit, ariaLabel, placeholder, className }: Inli
|
|
|
170
171
|
spellCheck={false}
|
|
171
172
|
onBlur={commit}
|
|
172
173
|
onKeyDown={onKeyDown}
|
|
173
|
-
className={cn(
|
|
174
|
-
"brand-inline-edit rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
175
|
-
className,
|
|
176
|
-
)}
|
|
174
|
+
className={cn("brand-inline-edit rounded-sm focus-ring", className)}
|
|
177
175
|
/>
|
|
178
176
|
);
|
|
179
177
|
}
|
|
180
178
|
|
|
181
179
|
/** `:::name` block directives → live @brand component with an editable body. */
|
|
182
180
|
function ContainerDirectiveView() {
|
|
181
|
+
const { t } = useLocale();
|
|
183
182
|
const { contentRef } = useNodeViewContext();
|
|
184
183
|
const { name, attributes, update } = useDirectiveAttrs();
|
|
185
184
|
|
|
@@ -192,8 +191,8 @@ function ContainerDirectiveView() {
|
|
|
192
191
|
<CardHeader className="pb-3">
|
|
193
192
|
<CardTitle>
|
|
194
193
|
<InlineEdit
|
|
195
|
-
ariaLabel="
|
|
196
|
-
placeholder="
|
|
194
|
+
ariaLabel={t("editor.directiveViews.cardTitle")}
|
|
195
|
+
placeholder={t("editor.directiveViews.cardTitle")}
|
|
197
196
|
value={attributes.title ?? ""}
|
|
198
197
|
onCommit={(v) => update("title", v)}
|
|
199
198
|
/>
|
|
@@ -216,7 +215,7 @@ function ContainerDirectiveView() {
|
|
|
216
215
|
flow, so its label must not join the document heading outline (see #21). */}
|
|
217
216
|
<div className="mb-1 font-medium leading-none tracking-tight">
|
|
218
217
|
<InlineEdit
|
|
219
|
-
ariaLabel="
|
|
218
|
+
ariaLabel={t("editor.directiveViews.calloutTitle")}
|
|
220
219
|
placeholder={capitalize(attributes.type ?? "note")}
|
|
221
220
|
value={attributes.title ?? ""}
|
|
222
221
|
onCommit={(v) => update("title", v)}
|
|
@@ -253,7 +252,9 @@ function ContainerDirectiveView() {
|
|
|
253
252
|
className="brand-directive brand-directive--unknown"
|
|
254
253
|
data-brand-directive={name}
|
|
255
254
|
>
|
|
256
|
-
<div className="mb-1 font-medium leading-none tracking-tight">
|
|
255
|
+
<div className="mb-1 font-medium leading-none tracking-tight">
|
|
256
|
+
{t("editor.directiveViews.unknownBlock", { name })}
|
|
257
|
+
</div>
|
|
257
258
|
<AlertDescription>{body}</AlertDescription>
|
|
258
259
|
</Alert>
|
|
259
260
|
);
|
|
@@ -495,6 +496,7 @@ function IterationMenuItems({
|
|
|
495
496
|
* inline (today's behaviour, unchanged).
|
|
496
497
|
*/
|
|
497
498
|
function IterationDirectiveView() {
|
|
499
|
+
const { t } = useLocale();
|
|
498
500
|
const { contentRef, node, getPos, setAttrs } = useNodeViewContext();
|
|
499
501
|
const { name, attributes } = useDirectiveAttrs();
|
|
500
502
|
const [, getInstance] = useInstance();
|
|
@@ -580,7 +582,7 @@ function IterationDirectiveView() {
|
|
|
580
582
|
attributes: attributes as Record<string, string>,
|
|
581
583
|
}).cells.length > 0;
|
|
582
584
|
|
|
583
|
-
const disabledHint = "
|
|
585
|
+
const disabledHint = t("editor.directiveViews.needsEmbeddedValues");
|
|
584
586
|
|
|
585
587
|
const convertToStatic = () => {
|
|
586
588
|
const template = readBodyMarkdown(getInstance as GetEditor, node);
|
|
@@ -592,14 +594,14 @@ function IterationDirectiveView() {
|
|
|
592
594
|
{
|
|
593
595
|
type: "item",
|
|
594
596
|
id: "edit",
|
|
595
|
-
label: "
|
|
597
|
+
label: t("editor.directiveViews.editIteration"),
|
|
596
598
|
icon: <Pencil className="size-4" aria-hidden="true" />,
|
|
597
599
|
onSelect: requestEdit,
|
|
598
600
|
},
|
|
599
601
|
{
|
|
600
602
|
type: "layout",
|
|
601
603
|
id: "layout",
|
|
602
|
-
label: "
|
|
604
|
+
label: t("editor.directiveViews.changeLayout"),
|
|
603
605
|
icon: <LayoutGrid className="size-4" aria-hidden="true" />,
|
|
604
606
|
value: (attributes.layout as IterationLayout) || ITERATION_LAYOUTS[kind][0]!,
|
|
605
607
|
options: ITERATION_LAYOUTS[kind],
|
|
@@ -610,7 +612,9 @@ function IterationDirectiveView() {
|
|
|
610
612
|
{
|
|
611
613
|
type: "item",
|
|
612
614
|
id: "transpose",
|
|
613
|
-
label: hasEmbeddedData
|
|
615
|
+
label: hasEmbeddedData
|
|
616
|
+
? t("editor.directiveViews.transpose")
|
|
617
|
+
: `${t("editor.directiveViews.transpose")} ${disabledHint}`,
|
|
614
618
|
icon: <ArrowLeftRight className="size-4" aria-hidden="true" />,
|
|
615
619
|
onSelect: transpose,
|
|
616
620
|
disabled: !hasEmbeddedData,
|
|
@@ -620,7 +624,9 @@ function IterationDirectiveView() {
|
|
|
620
624
|
{
|
|
621
625
|
type: "item",
|
|
622
626
|
id: "convert-to-static",
|
|
623
|
-
label: hasEmbeddedData
|
|
627
|
+
label: hasEmbeddedData
|
|
628
|
+
? t("editor.directiveViews.convertToStatic")
|
|
629
|
+
: `${t("editor.directiveViews.convertToStatic")} ${disabledHint}`,
|
|
624
630
|
icon: <FileText className="size-4" aria-hidden="true" />,
|
|
625
631
|
onSelect: convertToStatic,
|
|
626
632
|
disabled: !hasEmbeddedData,
|
|
@@ -630,11 +636,15 @@ function IterationDirectiveView() {
|
|
|
630
636
|
const header = (
|
|
631
637
|
<div className="mb-1.5 flex items-center gap-1.5 text-meta font-medium text-info-text">
|
|
632
638
|
<Icon className="size-3.5 shrink-0" aria-hidden="true" />
|
|
633
|
-
<span>{isPivot ? "
|
|
639
|
+
<span>{isPivot ? t("editor.directiveViews.pivot") : t("editor.directiveViews.iterate")}</span>
|
|
634
640
|
{!isPivot && attributes.as ? (
|
|
635
|
-
<span className="font-normal text-muted-foreground"
|
|
641
|
+
<span className="font-normal text-muted-foreground">
|
|
642
|
+
{t("editor.directiveViews.perItem", { as: attributes.as })}
|
|
643
|
+
</span>
|
|
636
644
|
) : null}
|
|
637
|
-
<span className="font-normal text-muted-foreground"
|
|
645
|
+
<span className="font-normal text-muted-foreground">
|
|
646
|
+
{t("editor.directiveViews.templateSuffix")}
|
|
647
|
+
</span>
|
|
638
648
|
{onEdit ? (
|
|
639
649
|
<DropdownMenu>
|
|
640
650
|
<DropdownMenuTrigger asChild>
|
|
@@ -642,9 +652,9 @@ function IterationDirectiveView() {
|
|
|
642
652
|
type="button"
|
|
643
653
|
// `data-directive-chrome` routes the click to the browser, not ProseMirror.
|
|
644
654
|
data-directive-chrome=""
|
|
645
|
-
aria-label="
|
|
646
|
-
title="
|
|
647
|
-
className="ms-auto inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground focus-
|
|
655
|
+
aria-label={t("editor.directiveViews.iterationActions")}
|
|
656
|
+
title={t("editor.directiveViews.iterationActionsTitle")}
|
|
657
|
+
className="ms-auto inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground focus-ring"
|
|
648
658
|
>
|
|
649
659
|
<MoreHorizontal className="size-4" aria-hidden="true" />
|
|
650
660
|
</button>
|
|
@@ -703,6 +713,7 @@ function IterationDirectiveView() {
|
|
|
703
713
|
|
|
704
714
|
/** `::name` leaf directives (e.g. `::metric`) → live, atomic @brand component. */
|
|
705
715
|
function LeafDirectiveView() {
|
|
716
|
+
const { t } = useLocale();
|
|
706
717
|
const { name, attributes, update } = useDirectiveAttrs();
|
|
707
718
|
|
|
708
719
|
if (name !== "metric") {
|
|
@@ -711,7 +722,7 @@ function LeafDirectiveView() {
|
|
|
711
722
|
className="brand-directive brand-directive--leaf brand-directive--unknown rounded-md border border-destructive/40 bg-surface-muted p-3 text-sm text-muted-foreground"
|
|
712
723
|
data-brand-leaf={name}
|
|
713
724
|
>
|
|
714
|
-
|
|
725
|
+
{t("editor.directiveViews.unknownInlineBlock")} <code>::{name}</code>
|
|
715
726
|
</div>
|
|
716
727
|
);
|
|
717
728
|
}
|
|
@@ -723,16 +734,16 @@ function LeafDirectiveView() {
|
|
|
723
734
|
data-brand-leaf="metric"
|
|
724
735
|
label={
|
|
725
736
|
<InlineEdit
|
|
726
|
-
ariaLabel="
|
|
727
|
-
placeholder="
|
|
737
|
+
ariaLabel={t("editor.directiveViews.metricLabel")}
|
|
738
|
+
placeholder={t("editor.directiveViews.metricLabelPlaceholder")}
|
|
728
739
|
value={attributes.label ?? ""}
|
|
729
740
|
onCommit={(v) => update("label", v)}
|
|
730
741
|
/>
|
|
731
742
|
}
|
|
732
743
|
value={
|
|
733
744
|
<InlineEdit
|
|
734
|
-
ariaLabel="
|
|
735
|
-
placeholder="
|
|
745
|
+
ariaLabel={t("editor.directiveViews.metricValue")}
|
|
746
|
+
placeholder={t("editor.directiveViews.metricValuePlaceholder")}
|
|
736
747
|
value={attributes.value ?? ""}
|
|
737
748
|
onCommit={(v) => update("value", v)}
|
|
738
749
|
className="min-w-[1ch]"
|
|
@@ -3,12 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Milkdown core ships ZERO styles (headless), so this is where brand-ui owns the
|
|
5
5
|
* look. Every value is a semantic design token (var(--foreground), --border,
|
|
6
|
-
* --radius, …) — NO raw colors — so the editor matches
|
|
6
|
+
* --radius, …) — NO raw colors — so the editor matches every theme
|
|
7
7
|
* (light/dark) for free. Every rule
|
|
8
8
|
* is scoped under `.milkdown-host` so nothing leaks globally.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
.milkdown-host .ProseMirror {
|
|
12
|
+
/* Suppresses the platform default outline — NOT a bare removal: the
|
|
13
|
+
`:focus-visible` rule below is the replacement, drawn on this same
|
|
14
|
+
element (#309). It owns the indicator unconditionally, so it can't be
|
|
15
|
+
clipped by an ancestor and can't be deleted by a consumer `className`
|
|
16
|
+
reaching `cn()` on the wrapper (`markdown-editor.tsx`'s root `div`
|
|
17
|
+
intentionally carries no focus-ring utility of its own — see #67/#309). */
|
|
12
18
|
outline: none;
|
|
13
19
|
padding: 1rem 1.1rem;
|
|
14
20
|
min-height: 9rem;
|
|
@@ -23,8 +29,25 @@
|
|
|
23
29
|
margin-inline: auto;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
/* Focus indicator (#309) — a compound two-layer shape mirroring the
|
|
33
|
+
`focus-ring-inset` Tailwind utility (`packages/tokens/src/themes.css`,
|
|
34
|
+
ADR 0027), hand-written because this stylesheet is a plain side-effect CSS
|
|
35
|
+
import resolved by the CONSUMER's bundler — not guaranteed to run through
|
|
36
|
+
Tailwind, so `@apply`/utility classes aren't reachable here (verified:
|
|
37
|
+
`@apply` appears zero times in any .css file in this repo). Drawn INSIDE
|
|
38
|
+
the editable's own box (`outline-offset: -3px`, an inset `box-shadow`), so
|
|
39
|
+
it can't be clipped by an ancestor and survives a consumer `className`
|
|
40
|
+
deleting the wrapper's classes (`MarkdownWorkspace` already does this via
|
|
41
|
+
`className="border-0"`). Two layers of opposite value so at least one edge
|
|
42
|
+
clears 3:1 against the ground whatever `--ring` resolves to in a given
|
|
43
|
+
theme (`--ring-contour` is the dedicated contour token — `themes-contrast.
|
|
44
|
+
test.ts`'s `INDICATOR_SURFACES` locks `max(ring, contour) >= 3:1` at the
|
|
45
|
+
token level; `markdown-editor.stories.tsx`'s `FocusIndicator` play
|
|
46
|
+
re-measures it on this rendered surface, both themes). */
|
|
47
|
+
.milkdown-host .ProseMirror:focus-visible {
|
|
48
|
+
outline: 1px solid var(--ring-contour);
|
|
49
|
+
outline-offset: -3px;
|
|
50
|
+
box-shadow: inset 0 0 0 2px var(--ring);
|
|
28
51
|
}
|
|
29
52
|
|
|
30
53
|
/* ----------------------------------------------------------------------------
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
// Read the stylesheet as text. The repo runs vitest per-package (turbo /
|
|
6
|
+
// `--filter`), so cwd is the package root — `src/...` resolves deterministically.
|
|
7
|
+
const css = readFileSync(resolve(process.cwd(), "src/markdown-editor/markdown-editor.css"), "utf8");
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* #309: `.ProseMirror` used to suppress the platform focus outline
|
|
11
|
+
* (`outline: none`, repeated as a dead no-op at `:focus`) with no replacement
|
|
12
|
+
* on the element itself — the only stand-in lived on a DIFFERENT element in a
|
|
13
|
+
* different file (`markdown-editor.tsx`'s wrapper `focus-ring-within` class),
|
|
14
|
+
* an implicit, unlocked, easily-overridden dependency raw CSS can't express.
|
|
15
|
+
* jsdom doesn't apply real focus/outline rendering, so this locks the
|
|
16
|
+
* stylesheet TEXT deterministically; `markdown-editor.stories.tsx`'s
|
|
17
|
+
* `FocusIndicator` play is the rendered-surface proof (resolved computed
|
|
18
|
+
* values, real contrast, both themes).
|
|
19
|
+
*/
|
|
20
|
+
describe("editor focus indicator CSS (regression, #309)", () => {
|
|
21
|
+
it("gives the editable its own :focus-visible indicator with a real outline", () => {
|
|
22
|
+
const focusVisibleRule = css.match(/\.milkdown-host \.ProseMirror:focus-visible\s*\{([^}]*)\}/);
|
|
23
|
+
expect(
|
|
24
|
+
focusVisibleRule,
|
|
25
|
+
"expected a .milkdown-host .ProseMirror:focus-visible rule",
|
|
26
|
+
).not.toBeNull();
|
|
27
|
+
const body = focusVisibleRule![1]!;
|
|
28
|
+
expect(body).toMatch(/outline:\s*(?!none\b)\S/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("never suppresses the outline on a :focus/:focus-visible rule with nothing else in it", () => {
|
|
32
|
+
// A rule whose ENTIRE body is `outline: none;` (whitespace only otherwise)
|
|
33
|
+
// on a focus selector is exactly the dead-suppression defect #309 found —
|
|
34
|
+
// catches a future edit re-adding `.ProseMirror:focus { outline: none; }`
|
|
35
|
+
// (or a :focus-visible equivalent) with no compensating declaration.
|
|
36
|
+
const focusRules = [...css.matchAll(/([.\w-]+:focus(?:-visible)?)\s*\{([^}]*)\}/g)];
|
|
37
|
+
for (const [, selector, body] of focusRules) {
|
|
38
|
+
const bareOutlineNone = /^\s*outline:\s*none;?\s*$/.test(body!);
|
|
39
|
+
expect(
|
|
40
|
+
bareOutlineNone,
|
|
41
|
+
`${selector} suppresses outline with no replacement in the same rule`,
|
|
42
|
+
).toBe(false);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("no longer contains the dead .ProseMirror:focus { outline: none; } rule", () => {
|
|
47
|
+
expect(css).not.toMatch(/\.milkdown-host \.ProseMirror:focus\s*\{\s*outline:\s*none;?\s*\}/);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
import { expect, waitFor, within } from "storybook/test";
|
|
2
|
+
import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
3
3
|
import { useRef, useState } from "react";
|
|
4
4
|
import { ThemeProvider } from "@elabs-ai/components-tokens";
|
|
5
5
|
import { MarkdownEditor, type MarkdownEditorHandle } from "./markdown-editor";
|
|
@@ -14,9 +14,12 @@ const meta = {
|
|
|
14
14
|
docs: {
|
|
15
15
|
description: {
|
|
16
16
|
component:
|
|
17
|
+
"The WYSIWYG markdown surface; the Monaco SOURCE editor is `Editor/CodeEditor` " +
|
|
18
|
+
"and the read-only render is `Editor/MarkdownPreview` — see " +
|
|
19
|
+
"[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
|
|
17
20
|
"A headless Milkdown (ProseMirror) WYSIWYG markdown surface vendored onto " +
|
|
18
21
|
"brand-ui. Companion to the Monaco `CodeEditor` (source) in the same package. " +
|
|
19
|
-
"Theming is 100% semantic tokens, so it tracks
|
|
22
|
+
"Theming is 100% semantic tokens, so it tracks every theme via `data-theme`. " +
|
|
20
23
|
"Depends on only `@milkdown/kit` — no Vue/Crepe.",
|
|
21
24
|
},
|
|
22
25
|
},
|
|
@@ -138,7 +141,7 @@ Two upstream data sources are still un-mapped.
|
|
|
138
141
|
* The brand `:::` directives render as the REAL @brand components inside the editor
|
|
139
142
|
* (a live `Card`, `Alert`, `MetricBlock`), with the card/callout title and the metric
|
|
140
143
|
* label/value editable inline — not the old token-styled `toDOM` chrome. Switch the
|
|
141
|
-
* Storybook theme to confirm they track
|
|
144
|
+
* Storybook theme to confirm they track every theme.
|
|
142
145
|
*/
|
|
143
146
|
export const LiveDirectives: Story = {
|
|
144
147
|
name: "Live directives (inline-editable)",
|
|
@@ -275,9 +278,9 @@ Prose continues normally after the table.
|
|
|
275
278
|
* - Tab moves to the next cell; Shift+Tab to the previous.
|
|
276
279
|
* - The serialized markdown (shown below the editor) stays lossless GFM.
|
|
277
280
|
*
|
|
278
|
-
*
|
|
281
|
+
* Cross-theme sweep: switch the Storybook theme toolbar to verify the table
|
|
279
282
|
* borders (`border-border-strong`), header tint (`bg-surface-muted`), and
|
|
280
|
-
* toolbar chrome use only semantic tokens across
|
|
283
|
+
* toolbar chrome use only semantic tokens across every theme.
|
|
281
284
|
*/
|
|
282
285
|
export const EditableTable: Story = {
|
|
283
286
|
name: "Editable table (wysiwyg-tables)",
|
|
@@ -342,7 +345,14 @@ export const FillsContainer: Story = {
|
|
|
342
345
|
await expect(editable).toBeVisible();
|
|
343
346
|
// The 1-line doc's editable fills the 700px container (well past the ~144px
|
|
344
347
|
// content floor) — clickable everywhere, with room for the `/` menu.
|
|
345
|
-
|
|
348
|
+
//
|
|
349
|
+
// Waited for, not read once: the fill chain is `.milkdown-host .ProseMirror.editor
|
|
350
|
+
// { flex: 1 1 auto }`, and Milkdown adds the `editor` class in a later commit than
|
|
351
|
+
// the one that puts `role="textbox"` in the DOM. Between the two the editable sits at
|
|
352
|
+
// its own `min-height: 9rem` (144px) inside an already-700px host — measured, not
|
|
353
|
+
// guessed. Reading `clientHeight` on the frame `findByRole` resolves therefore samples
|
|
354
|
+
// a real but transient layout; the settled condition is the one this story is about.
|
|
355
|
+
await waitFor(() => expect(editable.clientHeight).toBeGreaterThan(500), { timeout: 8000 });
|
|
346
356
|
},
|
|
347
357
|
};
|
|
348
358
|
|
|
@@ -504,7 +514,7 @@ export const IterationContextMenu: Story = {
|
|
|
504
514
|
};
|
|
505
515
|
|
|
506
516
|
/**
|
|
507
|
-
*
|
|
517
|
+
* Cross-theme sweep for the node menu (#223 round-2): both `NodeMenu` (⋯) and
|
|
508
518
|
* `IterationContextMenu` (right-click) above run only under the toolbar's
|
|
509
519
|
* DEFAULT theme (`light`) — Storybook's `defaultTheme` isn't overridable
|
|
510
520
|
* per-story from the global decorator alone, and toggling `preview.tsx` by hand
|
|
@@ -558,3 +568,114 @@ export const IterationContextMenuHighDecoration: Story = {
|
|
|
558
568
|
render: IterationContextMenu.render,
|
|
559
569
|
play: IterationContextMenu.play,
|
|
560
570
|
};
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* #309 — the editable's own compound focus indicator. Resolves the governing
|
|
574
|
+
* theme element FROM THE SUBJECT (`el.closest("[data-theme]")`, never a
|
|
575
|
+
* guessed ancestor — quality-gates.md § Theme-safe), reads RESOLVED computed
|
|
576
|
+
* values (not the class string — a class-string assertion passed on the
|
|
577
|
+
* previous, invisible wrapper ring, which is exactly how this shipped), and
|
|
578
|
+
* asserts at least one of the indicator's two layers clears 3:1 against the
|
|
579
|
+
* ground the editable sits on. `markdown-editor.focus.test.ts` locks the
|
|
580
|
+
* stylesheet TEXT; this is the rendered-surface proof.
|
|
581
|
+
*/
|
|
582
|
+
export const FocusIndicator: Story = {
|
|
583
|
+
name: "Focus indicator (#309)",
|
|
584
|
+
render: () => (
|
|
585
|
+
<div className="mx-auto max-w-3xl p-6">
|
|
586
|
+
<MarkdownEditor
|
|
587
|
+
defaultValue={"# Focus indicator\n\nClick or Tab into the editable below."}
|
|
588
|
+
aria-label="Markdown editor"
|
|
589
|
+
/>
|
|
590
|
+
</div>
|
|
591
|
+
),
|
|
592
|
+
play: async ({ canvasElement }) => {
|
|
593
|
+
const canvas = within(canvasElement);
|
|
594
|
+
const editable = await canvas.findByRole(
|
|
595
|
+
"textbox",
|
|
596
|
+
{ name: "Markdown editor" },
|
|
597
|
+
{ timeout: 8000 },
|
|
598
|
+
);
|
|
599
|
+
await expect(editable).toBeVisible();
|
|
600
|
+
expect(editable.classList.contains("ProseMirror")).toBe(true);
|
|
601
|
+
|
|
602
|
+
const wrapper = canvasElement.querySelector<HTMLElement>('[data-testid="markdown-editor"]');
|
|
603
|
+
expect(wrapper).not.toBeNull();
|
|
604
|
+
|
|
605
|
+
// Resolve ANY CSS colour string down to sRGB so a contrast ratio is a
|
|
606
|
+
// measurement, not an assumption — same helper `FlowNode`'s
|
|
607
|
+
// `FocusIndicator` lock uses for the flow-canvas half of this same fix
|
|
608
|
+
// family (#286).
|
|
609
|
+
const toSrgb = (colour: string): [number, number, number] => {
|
|
610
|
+
const surface = document.createElement("canvas");
|
|
611
|
+
surface.width = 1;
|
|
612
|
+
surface.height = 1;
|
|
613
|
+
const ctx = surface.getContext("2d")!;
|
|
614
|
+
ctx.fillStyle = colour;
|
|
615
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
616
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
617
|
+
return [r! / 255, g! / 255, b! / 255];
|
|
618
|
+
};
|
|
619
|
+
const luminance = ([r, g, b]: [number, number, number]) => {
|
|
620
|
+
const lin = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
|
|
621
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
622
|
+
};
|
|
623
|
+
const contrast = (a: string, b: string) => {
|
|
624
|
+
const [hi, lo] = [luminance(toSrgb(a)), luminance(toSrgb(b))].sort((x, y) => y - x);
|
|
625
|
+
return (hi! + 0.05) / (lo! + 0.05);
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
// Resting: no indicator on the editable, and the wrapper draws no ring of
|
|
629
|
+
// its own either (#309 — exactly one indicator, never a doubled one).
|
|
630
|
+
await expect(getComputedStyle(editable).outlineStyle).toBe("none");
|
|
631
|
+
await expect(getComputedStyle(wrapper!).boxShadow).toBe("none");
|
|
632
|
+
|
|
633
|
+
// Click into the editable. `:focus-visible` matches on pointer click for
|
|
634
|
+
// a contenteditable surface per spec (unlike a plain <input>/<button>),
|
|
635
|
+
// so this is a faithful proxy for the real "click to edit" path.
|
|
636
|
+
await userEvent.click(editable);
|
|
637
|
+
await waitFor(() => {
|
|
638
|
+
expect(getComputedStyle(editable).outlineStyle).toBe("solid");
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
// Exactly one indicator: the wrapper still draws nothing.
|
|
642
|
+
await expect(getComputedStyle(wrapper!).boxShadow).toBe("none");
|
|
643
|
+
|
|
644
|
+
const ground = getComputedStyle(wrapper!).backgroundColor;
|
|
645
|
+
const contourInk = getComputedStyle(editable).outlineColor;
|
|
646
|
+
const ringInk = getComputedStyle(editable).getPropertyValue("--ring").trim();
|
|
647
|
+
const contourRatio = contrast(contourInk, ground);
|
|
648
|
+
const ringRatio = contrast(ringInk, ground);
|
|
649
|
+
const bestRatio = Math.max(contourRatio, ringRatio);
|
|
650
|
+
await expect(
|
|
651
|
+
bestRatio,
|
|
652
|
+
`focus indicator vs editor ground: contour ${contourInk} = ${contourRatio.toFixed(2)}:1, ring ${ringInk} = ${ringRatio.toFixed(2)}:1`,
|
|
653
|
+
).toBeGreaterThanOrEqual(3);
|
|
654
|
+
|
|
655
|
+
// Blur restores the resting state.
|
|
656
|
+
(editable as HTMLElement).blur();
|
|
657
|
+
await waitFor(() => {
|
|
658
|
+
expect(getComputedStyle(editable).outlineStyle).toBe("none");
|
|
659
|
+
});
|
|
660
|
+
},
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
export const FocusIndicatorDark: Story = {
|
|
664
|
+
name: "Focus indicator (#309) — dark",
|
|
665
|
+
decorators: [
|
|
666
|
+
(Story) => (
|
|
667
|
+
<ThemeProvider defaultTheme="dark" storageKey={null}>
|
|
668
|
+
<Story />
|
|
669
|
+
</ThemeProvider>
|
|
670
|
+
),
|
|
671
|
+
],
|
|
672
|
+
render: FocusIndicator.render,
|
|
673
|
+
play: FocusIndicator.play,
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
export const FocusIndicatorHighDecoration: Story = {
|
|
677
|
+
name: "Focus indicator (#309) — high decoration",
|
|
678
|
+
globals: { decoration: "10" },
|
|
679
|
+
render: FocusIndicator.render,
|
|
680
|
+
play: FocusIndicator.play,
|
|
681
|
+
};
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* - Engine dep: only `@milkdown/kit` (headless). React glue is vendored under
|
|
9
9
|
* ./milkdown-react so we never pull `@milkdown/react` → `@milkdown/crepe` → Vue.
|
|
10
|
-
* - Theming: token-driven via markdown-editor.css (
|
|
10
|
+
* - Theming: token-driven via markdown-editor.css (every theme, no raw color).
|
|
11
11
|
* - Controlled (`value` + `onChange`) or uncontrolled (`defaultValue`). Mirrors the
|
|
12
12
|
* platform: `isControlled = value !== undefined`; never flips between modes.
|
|
13
13
|
* - StrictMode-safe (see markdown-editor.strictmode.test.tsx).
|
|
@@ -527,10 +527,13 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|
|
527
527
|
<div
|
|
528
528
|
data-testid="markdown-editor"
|
|
529
529
|
className={cn(
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
|
|
530
|
+
// No focus-ring utility here (deliberately, #309): the editable
|
|
531
|
+
// `.ProseMirror` element owns the compound focus indicator itself
|
|
532
|
+
// (`markdown-editor.css`'s `:focus-visible` rule), so it renders
|
|
533
|
+
// even when a consumer `className` overrides this wrapper's
|
|
534
|
+
// classes (`MarkdownWorkspace` does exactly that via
|
|
535
|
+
// `className="border-0"`) and never doubles up with a wrapper ring.
|
|
536
|
+
"milkdown-host overflow-auto rounded-md border border-border bg-background text-foreground",
|
|
534
537
|
className,
|
|
535
538
|
)}
|
|
536
539
|
// Publish the shared markdown scale as CSS vars the editor CSS reads, so the
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { createElement } from "react";
|
|
3
|
+
import { afterEach, expect, test, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { MarkdownEditor } from "../markdown-editor";
|
|
6
|
+
import { waitForPendingMilkdownTeardown } from "./use-get-editor";
|
|
7
|
+
|
|
8
|
+
afterEach(cleanup);
|
|
9
|
+
|
|
10
|
+
// @milkdown/ctx's internal `Timer` (armed by `Ctx.wait()`, which
|
|
11
|
+
// `@milkdown/core`'s `create()`/`destroy()` call internally for every
|
|
12
|
+
// `createTimer(name)` — e.g. `ConfigReady`, `InitReady`, `EditorViewReady`)
|
|
13
|
+
// uses `TimerType`'s default delay: `createTimer(name, timeout = 3e3)`,
|
|
14
|
+
// never overridden anywhere in this repo's editor setup.
|
|
15
|
+
const MILKDOWN_TIMER_DELAY_MS = 3000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Locks issue #84: `@milkdown/ctx`'s `Timer` class starts a 3-second
|
|
19
|
+
* `setTimeout` on every wait and never clears the handle on the resolve
|
|
20
|
+
* path — only the DOM/global event listener is removed
|
|
21
|
+
* (`lib/index.js:281-288`, vendored `@milkdown/ctx@7.21.2`). That leaves the
|
|
22
|
+
* timer armed for up to 3s after the promise it backs has already settled,
|
|
23
|
+
* and if it fires after Vitest has recycled the test file's jsdom
|
|
24
|
+
* environment, its callback's bare (unqualified) `removeEventListener` call
|
|
25
|
+
* throws `ReferenceError: removeEventListener is not defined` — the
|
|
26
|
+
* mechanism behind #65's originally-reported error.
|
|
27
|
+
*
|
|
28
|
+
* A bare "was `clearTimeout` called at some point" assertion would pass
|
|
29
|
+
* regardless of this bug, since unrelated React/ProseMirror machinery also
|
|
30
|
+
* calls `clearTimeout`. So this test tracks every `setTimeout` handle armed
|
|
31
|
+
* with Milkdown's exact 3-second delay, and every `clearTimeout` call, and
|
|
32
|
+
* asserts each such handle is cleared once Milkdown's own teardown
|
|
33
|
+
* (`waitForPendingMilkdownTeardown`, tracked in `use-get-editor.ts`) has
|
|
34
|
+
* settled. Against the unpatched `@milkdown/ctx` dependency this fails (no
|
|
35
|
+
* `clearTimeout` call in the vendored file at all — `grep -c clearTimeout`
|
|
36
|
+
* returns 0); the `patches/@milkdown__ctx@7.21.2.patch` fix (storing the
|
|
37
|
+
* handle and clearing it in `#removeListener`, which both the resolve and
|
|
38
|
+
* reject/timeout paths already call) makes it pass.
|
|
39
|
+
*/
|
|
40
|
+
test("clears the vendored @milkdown/ctx wait timer on teardown (#84)", async () => {
|
|
41
|
+
const originalSetTimeout = globalThis.setTimeout;
|
|
42
|
+
const originalClearTimeout = globalThis.clearTimeout;
|
|
43
|
+
|
|
44
|
+
const armedHandles = new Set<ReturnType<typeof setTimeout>>();
|
|
45
|
+
const clearedHandles = new Set<Parameters<typeof clearTimeout>[0]>();
|
|
46
|
+
|
|
47
|
+
vi.spyOn(globalThis, "setTimeout").mockImplementation(((
|
|
48
|
+
handler: TimerHandler,
|
|
49
|
+
timeout?: number,
|
|
50
|
+
...args: unknown[]
|
|
51
|
+
) => {
|
|
52
|
+
const handle = originalSetTimeout(handler as never, timeout, ...args);
|
|
53
|
+
if (timeout === MILKDOWN_TIMER_DELAY_MS) armedHandles.add(handle);
|
|
54
|
+
return handle;
|
|
55
|
+
}) as unknown as typeof setTimeout);
|
|
56
|
+
|
|
57
|
+
vi.spyOn(globalThis, "clearTimeout").mockImplementation(((
|
|
58
|
+
handle?: Parameters<typeof clearTimeout>[0],
|
|
59
|
+
) => {
|
|
60
|
+
clearedHandles.add(handle);
|
|
61
|
+
return originalClearTimeout(handle as never);
|
|
62
|
+
}) as unknown as typeof clearTimeout);
|
|
63
|
+
|
|
64
|
+
const { unmount } = render(createElement(MarkdownEditor, { defaultValue: "# Hello Timer" }));
|
|
65
|
+
await waitFor(() => expect(screen.getByText("Hello Timer")).toBeInTheDocument());
|
|
66
|
+
|
|
67
|
+
unmount();
|
|
68
|
+
await waitForPendingMilkdownTeardown();
|
|
69
|
+
|
|
70
|
+
vi.restoreAllMocks();
|
|
71
|
+
|
|
72
|
+
// Sanity: the mechanism under test actually armed at least one 3-second
|
|
73
|
+
// wait timer during create()/destroy() — otherwise the loop below would
|
|
74
|
+
// trivially pass over an empty set.
|
|
75
|
+
expect(armedHandles.size).toBeGreaterThan(0);
|
|
76
|
+
|
|
77
|
+
for (const handle of armedHandles) {
|
|
78
|
+
expect(clearedHandles.has(handle)).toBe(true);
|
|
79
|
+
}
|
|
80
|
+
});
|