@charcuterie/ui 3.25.0 → 3.27.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 +4 -2
- package/dist/Board/Board.d.ts +20 -7
- package/dist/Board/Board.d.ts.map +1 -1
- package/dist/Board/Board.js +16 -10
- package/dist/Board/Board.js.map +1 -1
- package/dist/Board/BoardCard.d.ts +23 -10
- package/dist/Board/BoardCard.d.ts.map +1 -1
- package/dist/Board/BoardCard.js +44 -22
- package/dist/Board/BoardCard.js.map +1 -1
- package/dist/Board/BoardLaneList.d.ts +2 -1
- package/dist/Board/BoardLaneList.d.ts.map +1 -1
- package/dist/Board/BoardLaneList.js +13 -4
- package/dist/Board/BoardLaneList.js.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.d.ts +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.js +130 -74
- package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +135 -85
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -1
- package/package.json +1 -1
- package/src/Board/Board.mdx +17 -0
- package/src/Board/Board.stories.tsx +88 -0
- package/src/Board/Board.test.tsx +66 -0
- package/src/Board/Board.tsx +34 -8
- package/src/Board/BoardCard.tsx +66 -23
- package/src/Board/BoardLaneList.tsx +35 -5
- package/src/MarkdownEditor/MarkdownEditor.mdx +32 -4
- package/src/MarkdownEditor/MarkdownEditor.test.tsx +108 -0
- package/src/MarkdownEditor/MarkdownEditor.tsx +89 -0
- package/src/icons.storyHelpers.tsx +11 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +3 -1
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +31 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.test.tsx +109 -1
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +84 -0
|
@@ -625,3 +625,111 @@ test("the hint stays described when the container is narrow", async () => {
|
|
|
625
625
|
|
|
626
626
|
await expectNoAxeViolations(canvasElement)
|
|
627
627
|
})
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* THE IMAGE BUTTON, and the reason it is in the toolbar rather
|
|
631
|
+
* than beside the editor.
|
|
632
|
+
*
|
|
633
|
+
* The editor already took a paste and a drop. Neither exists on a
|
|
634
|
+
* tablet, so the only way in there was a `FileDropZone` under the
|
|
635
|
+
* box — which appends to the END of the document, because pressing
|
|
636
|
+
* it moves focus out of the editor. An image in prose belongs at
|
|
637
|
+
* the point it is explained, so the control has to be one the
|
|
638
|
+
* editor owns.
|
|
639
|
+
*
|
|
640
|
+
* ⚠️ It is the FOURTH action, before the lists, because `Toolbar`
|
|
641
|
+
* overflows from the end and last put it behind "More actions" at
|
|
642
|
+
* 900px. It still collapses on a narrow enough surface, so a test
|
|
643
|
+
* cannot assume the button is on the bar.
|
|
644
|
+
*/
|
|
645
|
+
const openImageButton = async (
|
|
646
|
+
canvas: Canvas,
|
|
647
|
+
body: Canvas,
|
|
648
|
+
) => {
|
|
649
|
+
const onTheBar = canvas.queryByRole("button", {
|
|
650
|
+
name: "Image",
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
if (onTheBar) {
|
|
654
|
+
return onTheBar
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
await userEvent.click(
|
|
658
|
+
canvas.getByRole("button", { name: "More actions" }),
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
// The menu is PORTALLED, so it is on `body` and not inside the
|
|
662
|
+
// story's canvas.
|
|
663
|
+
return body.getByRole("menuitem", { name: "Image" })
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
test("the toolbar offers Image when uploads are wired", async () => {
|
|
667
|
+
const { body, canvas } = await mountStory(Interactive)
|
|
668
|
+
|
|
669
|
+
await expect(
|
|
670
|
+
await openImageButton(canvas, body),
|
|
671
|
+
).not.toBeNull()
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
/** A button that opens a file picker and then has nowhere to send
|
|
675
|
+
* the file is worse than no button. */
|
|
676
|
+
test("the toolbar hides Image when nothing can take the file", async () => {
|
|
677
|
+
const { body, canvas } = await mountStory(NoUpload)
|
|
678
|
+
|
|
679
|
+
const overflow = canvas.queryByRole("button", {
|
|
680
|
+
name: "More actions",
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
if (overflow) {
|
|
684
|
+
await userEvent.click(overflow)
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
await expect(
|
|
688
|
+
body.queryByRole("button", { name: "Image" }),
|
|
689
|
+
).toBeNull()
|
|
690
|
+
|
|
691
|
+
await expect(
|
|
692
|
+
body.queryByRole("menuitem", { name: "Image" }),
|
|
693
|
+
).toBeNull()
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
test("a picked file uploads and lands at the caret", async () => {
|
|
697
|
+
const { body, canvas } = await mountStory(Interactive)
|
|
698
|
+
|
|
699
|
+
const editor = getEditor(canvas, "Task description")
|
|
700
|
+
|
|
701
|
+
await userEvent.click(editor)
|
|
702
|
+
|
|
703
|
+
editor.setSelectionRange(0, 0)
|
|
704
|
+
|
|
705
|
+
await userEvent.click(await openImageButton(canvas, body))
|
|
706
|
+
|
|
707
|
+
const picker = canvas
|
|
708
|
+
.getByRole("toolbar")
|
|
709
|
+
.parentElement?.querySelector(
|
|
710
|
+
'input[type="file"]',
|
|
711
|
+
) as HTMLInputElement
|
|
712
|
+
|
|
713
|
+
await expect(picker).not.toBeNull()
|
|
714
|
+
|
|
715
|
+
// The picker itself is the browser's; what this drives is the
|
|
716
|
+
// `change` it fires on the way back.
|
|
717
|
+
await userEvent.upload(
|
|
718
|
+
picker,
|
|
719
|
+
new File(["binary"], "jig.png", { type: "image/png" }),
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
await waitFor(() => {
|
|
723
|
+
expect(editor.value).toContain(
|
|
724
|
+
"",
|
|
725
|
+
)
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
// At the caret — offset 0 — not appended to the end.
|
|
729
|
+
await expect(editor.value.startsWith(").toBe(
|
|
730
|
+
true,
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
// And it is still markdown, with no tag anywhere.
|
|
734
|
+
await expect(editor.value).not.toContain("<img")
|
|
735
|
+
})
|
|
@@ -63,6 +63,7 @@ export type MarkdownEditorIcons = Partial<
|
|
|
63
63
|
| "bulletedList"
|
|
64
64
|
| "code"
|
|
65
65
|
| "heading"
|
|
66
|
+
| "image"
|
|
66
67
|
| "italic"
|
|
67
68
|
| "link"
|
|
68
69
|
| "numberedList"
|
|
@@ -286,6 +287,12 @@ export const MarkdownEditor = ({
|
|
|
286
287
|
|
|
287
288
|
const layerRef = useRef<HTMLDivElement>(null)
|
|
288
289
|
|
|
290
|
+
/** The toolbar's Image button presses this. It is a real
|
|
291
|
+
* `<input type="file">` because nothing else opens a file
|
|
292
|
+
* picker, and it is the only way in on a touch device — there
|
|
293
|
+
* is nothing to paste from and nothing to drag. */
|
|
294
|
+
const imageInputRef = useRef<HTMLInputElement>(null)
|
|
295
|
+
|
|
289
296
|
const uploadCount = useRef(0)
|
|
290
297
|
|
|
291
298
|
const [value, setValue] = useState(defaultValue)
|
|
@@ -674,6 +681,54 @@ export const MarkdownEditor = ({
|
|
|
674
681
|
)
|
|
675
682
|
},
|
|
676
683
|
},
|
|
684
|
+
/*
|
|
685
|
+
IMAGE, and only when the consumer can actually take
|
|
686
|
+
one. A button that opens a file picker and then has
|
|
687
|
+
nowhere to send the file is worse than no button.
|
|
688
|
+
|
|
689
|
+
Spread rather than a `filter(Boolean)` so the array
|
|
690
|
+
stays a `ToolbarItem[]` without a cast.
|
|
691
|
+
|
|
692
|
+
⚠️ FOURTH, and the position is load-bearing rather
|
|
693
|
+
than aesthetic. `Toolbar` overflows from the END of
|
|
694
|
+
this list, so the order IS the priority order. Put
|
|
695
|
+
last, beside Link where it reads most naturally, and
|
|
696
|
+
Image was the first thing to collapse into "More
|
|
697
|
+
actions" — at 900px it was already hidden, and in a
|
|
698
|
+
comment box in a rail it never appeared at all. That
|
|
699
|
+
is the complaint this button exists to answer:
|
|
700
|
+
*"adding an image is way at the bottom"*. Hiding it
|
|
701
|
+
behind an overflow trigger moves the problem rather
|
|
702
|
+
than fixing it.
|
|
703
|
+
|
|
704
|
+
Fourth is visible wherever four actions fit — at
|
|
705
|
+
900px the bar carries eight. It still collapses on a
|
|
706
|
+
genuinely narrow editor (at 460px the bar carries
|
|
707
|
+
three), which is why a comment box in a rail keeps a
|
|
708
|
+
`FileDropZone` beside it. It also reads: emphasis,
|
|
709
|
+
then structure, then insert.
|
|
710
|
+
|
|
711
|
+
The caret is why this belongs in the toolbar at all
|
|
712
|
+
rather than beside the editor. A `FileDropZone` under
|
|
713
|
+
the box appends to the end of the document, because
|
|
714
|
+
pressing it moves focus out; the textarea keeps its
|
|
715
|
+
`selectionStart` while blurred, so the insertion
|
|
716
|
+
lands where the user last was — which is the whole
|
|
717
|
+
point of an image in prose.
|
|
718
|
+
*/
|
|
719
|
+
...(onUploadImage
|
|
720
|
+
? [
|
|
721
|
+
{
|
|
722
|
+
icon: icons?.image,
|
|
723
|
+
isDisabled: isDisabled || isReadOnly,
|
|
724
|
+
key: "image",
|
|
725
|
+
label: "Image",
|
|
726
|
+
onSelect: () => {
|
|
727
|
+
imageInputRef.current?.click()
|
|
728
|
+
},
|
|
729
|
+
},
|
|
730
|
+
]
|
|
731
|
+
: []),
|
|
677
732
|
{
|
|
678
733
|
icon: icons?.bulletedList,
|
|
679
734
|
isDisabled: isDisabled || isReadOnly,
|
|
@@ -755,6 +810,40 @@ export const MarkdownEditor = ({
|
|
|
755
810
|
overflow="menu"
|
|
756
811
|
overflowIcon={icons?.overflow}
|
|
757
812
|
/>
|
|
813
|
+
|
|
814
|
+
{/*
|
|
815
|
+
The Image button's actual control.
|
|
816
|
+
|
|
817
|
+
`hidden`, not `sr-only`: `display: none` takes it out of
|
|
818
|
+
the accessibility tree, which is what is wanted here —
|
|
819
|
+
the toolbar button is the named control, and a second
|
|
820
|
+
unlabelled file input announced beside it would be one
|
|
821
|
+
control too many. `.click()` on a hidden input opens the
|
|
822
|
+
picker in every browser the fleet runs.
|
|
823
|
+
|
|
824
|
+
`multiple`, because a drop already takes several and the
|
|
825
|
+
two ways in should not disagree. The value is cleared
|
|
826
|
+
after each pick so choosing the SAME file twice fires
|
|
827
|
+
`change` the second time.
|
|
828
|
+
*/}
|
|
829
|
+
{onUploadImage ? (
|
|
830
|
+
<input
|
|
831
|
+
accept="image/*"
|
|
832
|
+
aria-hidden="true"
|
|
833
|
+
className="hidden"
|
|
834
|
+
multiple
|
|
835
|
+
onChange={(changeEvent) => {
|
|
836
|
+
uploadImages(
|
|
837
|
+
Array.from(changeEvent.target.files ?? []),
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
changeEvent.target.value = ""
|
|
841
|
+
}}
|
|
842
|
+
ref={imageInputRef}
|
|
843
|
+
tabIndex={-1}
|
|
844
|
+
type="file"
|
|
845
|
+
/>
|
|
846
|
+
) : null}
|
|
758
847
|
</div>
|
|
759
848
|
|
|
760
849
|
<div className="grid">
|
|
@@ -295,6 +295,16 @@ export const LinkIcon = (): ReactNode => (
|
|
|
295
295
|
</svg>
|
|
296
296
|
)
|
|
297
297
|
|
|
298
|
+
export const ImageIcon = (): ReactNode => (
|
|
299
|
+
<svg {...iconProps} aria-hidden="true">
|
|
300
|
+
<rect height="16" rx="2" width="18" x="3" y="4" />
|
|
301
|
+
|
|
302
|
+
<path d="M3 15l5-5 4 4 3-3 6 6" />
|
|
303
|
+
|
|
304
|
+
<circle cx="8.5" cy="9" r="1.2" />
|
|
305
|
+
</svg>
|
|
306
|
+
)
|
|
307
|
+
|
|
298
308
|
/**
|
|
299
309
|
* The whole `MarkdownEditorIcons` bag, ready to spread into either
|
|
300
310
|
* editor's `icons` prop.
|
|
@@ -304,6 +314,7 @@ export const MARKDOWN_ICONS = {
|
|
|
304
314
|
bulletedList: <BulletedListIcon />,
|
|
305
315
|
code: <CodeIcon />,
|
|
306
316
|
heading: <HeadingIcon />,
|
|
317
|
+
image: <ImageIcon />,
|
|
307
318
|
italic: <ItalicIcon />,
|
|
308
319
|
link: <LinkIcon />,
|
|
309
320
|
numberedList: <NumberedListIcon />,
|
|
@@ -148,7 +148,9 @@ from either edge, or flip the Markdown source toggle, which is exactly what it i
|
|
|
148
148
|
|
|
149
149
|
## Pasting
|
|
150
150
|
|
|
151
|
-
- **An image**
|
|
151
|
+
- **An image** — pasted, dropped, or picked with the toolbar's **Image** action, which
|
|
152
|
+
appears only when `onUploadImage` is wired and is the only way in on a tablet — runs
|
|
153
|
+
`onUploadImage` and inserts `` at the caret, then
|
|
152
154
|
renders it in place. The component does not own storage — it hands you a `File`.
|
|
153
155
|
- **A URL over selected text** becomes `[selection](url)`, the way it does in every other
|
|
154
156
|
editor. Deliberately strict: one absolute `http(s)` URL and nothing else, because mangling
|
|
@@ -242,3 +242,34 @@ export const IconsVersusWords: Story = {
|
|
|
242
242
|
</StoryGrid>
|
|
243
243
|
),
|
|
244
244
|
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* With an upload wired, so the **Image** toolbar action appears.
|
|
248
|
+
*
|
|
249
|
+
* The editor already took a paste and a drop; neither exists on a
|
|
250
|
+
* tablet, and a `FileDropZone` beside the box appends to the end of
|
|
251
|
+
* the document because pressing it moves focus out. The toolbar
|
|
252
|
+
* button keeps the insertion at the caret, which is the whole point
|
|
253
|
+
* of an image in prose.
|
|
254
|
+
*
|
|
255
|
+
* The upload here is a fake that resolves after a beat, so the
|
|
256
|
+
* placeholder is visible while it runs. A real consumer returns a
|
|
257
|
+
* URL from its own blob storage.
|
|
258
|
+
*/
|
|
259
|
+
export const Uploading: Story = {
|
|
260
|
+
args: {
|
|
261
|
+
defaultValue:
|
|
262
|
+
"Press Image, or paste one, and it lands where the caret is.\n",
|
|
263
|
+
label: "Task description",
|
|
264
|
+
onUploadImage: async (file: File) => {
|
|
265
|
+
await new Promise((resolve) => {
|
|
266
|
+
setTimeout(resolve, 600)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
alt: file.name,
|
|
271
|
+
url: `https://example.invalid/blobs/${encodeURIComponent(file.name)}`,
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
}
|
|
@@ -5,9 +5,13 @@ import { test } from "vitest"
|
|
|
5
5
|
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
6
6
|
import * as stories from "./MarkdownEditorCodeMirror.stories.tsx"
|
|
7
7
|
|
|
8
|
-
const { AllStates, CaretReveal, Default } =
|
|
8
|
+
const { AllStates, CaretReveal, Default, Uploading } =
|
|
9
9
|
composeStories(stories)
|
|
10
10
|
|
|
11
|
+
type Canvas = Awaited<
|
|
12
|
+
ReturnType<typeof mountStory>
|
|
13
|
+
>["canvas"]
|
|
14
|
+
|
|
11
15
|
/**
|
|
12
16
|
* The two ways this surface stopped behaving like the `<textarea>`
|
|
13
17
|
* it replaced. Both are invisible to every other gate: the stories
|
|
@@ -183,3 +187,107 @@ test("a document taller than the frame grows it", async () => {
|
|
|
183
187
|
scroller.clientHeight,
|
|
184
188
|
)
|
|
185
189
|
})
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* THE IMAGE BUTTON, on the CodeMirror surface.
|
|
193
|
+
*
|
|
194
|
+
* Same control and same reason as the `<textarea>` sibling's: paste
|
|
195
|
+
* and drop do not exist on a tablet, and a `FileDropZone` beside the
|
|
196
|
+
* editor appends to the END of the document because pressing it
|
|
197
|
+
* moves focus out. Here the insertion goes through
|
|
198
|
+
* `readSelection(view)`, so it lands at the caret — CodeMirror keeps
|
|
199
|
+
* its selection while the file picker is open.
|
|
200
|
+
*
|
|
201
|
+
* ⚠️ Image is the FOURTH action, before the lists, because
|
|
202
|
+
* `Toolbar` overflows from the end and last put it behind the
|
|
203
|
+
* overflow trigger at 900px. It still collapses on a narrow enough
|
|
204
|
+
* surface, so a test cannot assume it is on the bar.
|
|
205
|
+
*/
|
|
206
|
+
const openImageButton = async (
|
|
207
|
+
canvas: Canvas,
|
|
208
|
+
body: Canvas,
|
|
209
|
+
) => {
|
|
210
|
+
const onTheBar = canvas.queryByRole("button", {
|
|
211
|
+
name: "Image",
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
if (onTheBar) {
|
|
215
|
+
return onTheBar
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await userEvent.click(
|
|
219
|
+
canvas.getByRole("button", { name: "More actions" }),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
// `overflow="panel"` here rather than `menu`, and the panel is
|
|
223
|
+
// PORTALLED — so it is on `body`, not inside the story canvas.
|
|
224
|
+
return body.getByRole("button", { name: "Image" })
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
test("the toolbar offers Image when uploads are wired", async () => {
|
|
228
|
+
const { body, canvas } = await mountStory(Uploading)
|
|
229
|
+
|
|
230
|
+
await expect(
|
|
231
|
+
await openImageButton(canvas, body),
|
|
232
|
+
).not.toBeNull()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
/** A button that opens a file picker and then has nowhere to send
|
|
236
|
+
* the file is worse than no button. */
|
|
237
|
+
test("the toolbar hides Image when nothing can take the file", async () => {
|
|
238
|
+
const { body, canvas } = await mountStory(Default)
|
|
239
|
+
|
|
240
|
+
const overflow = canvas.queryByRole("button", {
|
|
241
|
+
name: "More actions",
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
if (overflow) {
|
|
245
|
+
await userEvent.click(overflow)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await expect(
|
|
249
|
+
body.queryByRole("button", { name: "Image" }),
|
|
250
|
+
).toBeNull()
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test("a picked file uploads and lands in the document", async () => {
|
|
254
|
+
const { body, canvas } = await mountStory(Uploading)
|
|
255
|
+
|
|
256
|
+
await userEvent.click(await openImageButton(canvas, body))
|
|
257
|
+
|
|
258
|
+
const picker = canvas
|
|
259
|
+
.getByRole("toolbar")
|
|
260
|
+
.parentElement?.querySelector(
|
|
261
|
+
'input[type="file"]',
|
|
262
|
+
) as HTMLInputElement
|
|
263
|
+
|
|
264
|
+
await expect(picker).not.toBeNull()
|
|
265
|
+
|
|
266
|
+
// The picker itself is the browser's; what this drives is the
|
|
267
|
+
// `change` it fires on the way back.
|
|
268
|
+
await userEvent.upload(
|
|
269
|
+
picker,
|
|
270
|
+
new File(["binary"], "jig.png", { type: "image/png" }),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
/*
|
|
274
|
+
The document is CodeMirror's, so the assertion reads the
|
|
275
|
+
editing surface's text rather than a `value`.
|
|
276
|
+
|
|
277
|
+
NOT `getByRole("img")`, which is what this first tried: live
|
|
278
|
+
preview reveals a construct's markers while the caret is inside
|
|
279
|
+
it, and the caret lands immediately after the insertion — so
|
|
280
|
+
the image is markdown on screen at exactly this moment and
|
|
281
|
+
becomes a picture when the caret leaves. That is the surface
|
|
282
|
+
working, not failing.
|
|
283
|
+
*/
|
|
284
|
+
await waitFor(() => {
|
|
285
|
+
expect(
|
|
286
|
+
canvas.getByRole("textbox", {
|
|
287
|
+
name: "Task description",
|
|
288
|
+
}).textContent,
|
|
289
|
+
).toContain(
|
|
290
|
+
"",
|
|
291
|
+
)
|
|
292
|
+
})
|
|
293
|
+
})
|
|
@@ -300,6 +300,12 @@ export const MarkdownEditorCodeMirror = ({
|
|
|
300
300
|
|
|
301
301
|
const uploadCount = useRef(0)
|
|
302
302
|
|
|
303
|
+
/** The toolbar's Image button presses this. A real
|
|
304
|
+
* `<input type="file">` because nothing else opens a file
|
|
305
|
+
* picker, and it is the only way in on a touch device — there
|
|
306
|
+
* is nothing to paste from and nothing to drag. */
|
|
307
|
+
const imageInputRef = useRef<HTMLInputElement>(null)
|
|
308
|
+
|
|
303
309
|
const editabilityRef = useRef(new Compartment())
|
|
304
310
|
|
|
305
311
|
const [isRawMode, setIsRawMode] = useState(
|
|
@@ -732,6 +738,34 @@ export const MarkdownEditorCodeMirror = ({
|
|
|
732
738
|
)
|
|
733
739
|
},
|
|
734
740
|
},
|
|
741
|
+
/*
|
|
742
|
+
IMAGE, and only when the consumer can actually take
|
|
743
|
+
one. A button that opens a file picker and then has
|
|
744
|
+
nowhere to send the file is worse than no button.
|
|
745
|
+
|
|
746
|
+
Spread rather than a `filter(Boolean)`, so the array
|
|
747
|
+
stays a `ToolbarItem[]` without a cast.
|
|
748
|
+
|
|
749
|
+
⚠️ FOURTH, and the position is load-bearing rather
|
|
750
|
+
than aesthetic — same reason as the textarea
|
|
751
|
+
sibling's. `Toolbar` overflows from the END, so the
|
|
752
|
+
order IS the priority order, and last put Image
|
|
753
|
+
behind "More actions" at 900px. The whole point of
|
|
754
|
+
the button is that adding an image is not buried.
|
|
755
|
+
*/
|
|
756
|
+
...(onUploadImage
|
|
757
|
+
? [
|
|
758
|
+
{
|
|
759
|
+
icon: icons?.image,
|
|
760
|
+
isDisabled: isDisabled || isReadOnly,
|
|
761
|
+
key: "image",
|
|
762
|
+
label: "Image",
|
|
763
|
+
onSelect: () => {
|
|
764
|
+
imageInputRef.current?.click()
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
]
|
|
768
|
+
: []),
|
|
735
769
|
{
|
|
736
770
|
icon: icons?.bulletedList,
|
|
737
771
|
isDisabled: isDisabled || isReadOnly,
|
|
@@ -832,6 +866,56 @@ export const MarkdownEditorCodeMirror = ({
|
|
|
832
866
|
overflow="panel"
|
|
833
867
|
overflowIcon={icons?.overflow}
|
|
834
868
|
/>
|
|
869
|
+
|
|
870
|
+
{/*
|
|
871
|
+
The Image button's actual control.
|
|
872
|
+
|
|
873
|
+
`hidden`, not `sr-only`: `display: none` takes it out of
|
|
874
|
+
the accessibility tree, which is what is wanted — the
|
|
875
|
+
toolbar button is the named control, and a second
|
|
876
|
+
unlabelled file input announced beside it would be one
|
|
877
|
+
control too many.
|
|
878
|
+
|
|
879
|
+
The insertion lands at the CARET, not at the end of the
|
|
880
|
+
document, because `readSelection(view)` reads
|
|
881
|
+
CodeMirror's own selection and the view keeps it while
|
|
882
|
+
the picker is open. That is the whole reason this is a
|
|
883
|
+
toolbar button rather than a drop zone under the box.
|
|
884
|
+
*/}
|
|
885
|
+
{onUploadImage ? (
|
|
886
|
+
<input
|
|
887
|
+
accept="image/*"
|
|
888
|
+
aria-hidden="true"
|
|
889
|
+
className="hidden"
|
|
890
|
+
multiple
|
|
891
|
+
onChange={(changeEvent) => {
|
|
892
|
+
const view = viewRef.current
|
|
893
|
+
|
|
894
|
+
if (view) {
|
|
895
|
+
for (const file of Array.from(
|
|
896
|
+
changeEvent.target.files ?? [],
|
|
897
|
+
).filter(isImageFile)) {
|
|
898
|
+
uploadCount.current += 1
|
|
899
|
+
|
|
900
|
+
void uploadImage({
|
|
901
|
+
count: uploadCount.current,
|
|
902
|
+
file,
|
|
903
|
+
handlers: handlersRef.current,
|
|
904
|
+
setMessage: setUploadMessage,
|
|
905
|
+
view,
|
|
906
|
+
})
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Cleared so choosing the SAME file twice fires
|
|
911
|
+
// `change` the second time.
|
|
912
|
+
changeEvent.target.value = ""
|
|
913
|
+
}}
|
|
914
|
+
ref={imageInputRef}
|
|
915
|
+
tabIndex={-1}
|
|
916
|
+
type="file"
|
|
917
|
+
/>
|
|
918
|
+
) : null}
|
|
835
919
|
</div>
|
|
836
920
|
|
|
837
921
|
{/*
|