Rich text editor
View on GitHubAdd image uploads to another surface
How the upload pipeline from
02-add-image-paste-and-drop-uploads.mdRich text editorAdd image paste & drop uploadsWalkthrough of the pipeline that lets an issue description accept an image pasted with Ctrl+V or dragged onto the page — every layer of it, in the order yo…
reaches the app's markdown fields that are not Tiptap: Part A
issue comments and Part B an issue type's template body. Both are
built; together they are the two shapes any further surface will
take — one that has to render the markdown itself (comments) and one
whose rendering is somebody else's problem (templates).
Read Part A first even if you only care about templates — every helper Part B uses comes from it, and the two differ in exactly the ways listed in Part B's opening.
No backend work is needed for either. POST /projects/{project}/attachments is deliberately scoped to a project
rather than to an issue, so the same endpoint, the same
AttachmentService, the same NSFW screening and the same
useImageUpload hook serve every surface. If you find yourself
adding a second controller or a comment_id column, stop — that is
the design being worked around, not extended.
The three jobs a non-Tiptap surface has to do itself
Neither the comment box nor the template body is a Tiptap editor;
both are plain TextArea atoms holding markdown as a string. So
there is no insertContentAt to call and no Image node to render,
and each surface needs all three of:
- Splice
into the string at the caret, then put the caret back — a<textarea>loses its selection the moment React re-renders it with a newvalue. - Do that for the edit path too, not just for composing.
- Render the stored markdown as an actual image, or the reader
sees literal
text.
Part A does all three; miss the third and everything looks like it works until you reload. Part B only does the first two, for the reason given there.
Step 1 — The shared textarea helpers
File: resources/js/utils/imagePaste.ts
extractImageFiles (guide 02, step 9) already serves paste and drop.
Two more helpers live next to it, and both surfaces use them
unchanged:
/**
* Splices a markdown image link into a textarea's value, replacing whatever
* the given range covers, and reports where the caret has to be restored -
* a textarea drops its selection as soon as React re-renders it with a new
* value.
*/
export const insertMarkdownImage = (
body: string,
range: { start: number; end: number },
file: File,
url: string,
): { body: string; caret: number; length: number } => {
const snippet = ``;
return {
body: body.slice(0, range.start) + snippet + body.slice(range.end),
caret: range.start + snippet.length,
length: snippet.length,
};
};
Alt text is the original filename, put through markdownImageAlt(),
which drops the brackets, parentheses and backslashes that would
close the link early — a file named screen](old).png would
otherwise produce a body that no longer parses as an image at all.
EditableMarkdown runs the same function over the alt attribute of
its image node, so a screenshot pasted into a description and one
pasted into a comment produce identical markdown.
length exists for one caller only: CommentForm, which has to tell
its mention bookkeeping how many characters appeared (step 3).
The third helper is what makes a multi-file paste come out in order:
/**
* Where the next image of a batch has to land: a collapsed point right after
* the one just inserted. Uploading a batch in parallel and reusing the
* original range for every file inserts them in reverse order, and - when the
* range covered a selection - lets a later insertion cut through the markdown
* an earlier one already wrote.
*/
export const nextImageRange = (
range: { start: number; end: number },
file: File,
url: string,
): { start: number; end: number } => {
const caret = range.start + markdownImage(file, url).length;
return { start: caret, end: caret };
};
It works out the next insertion point from the snippet rather than from the new body, which is what lets the caller advance without reading state back out of React.
And, for rendering:
export interface MarkdownImageSegment {
type: 'text' | 'image';
/** The raw text, or - for an image - its alt text. */
value: string;
url?: string;
}
// Deliberately narrow: no whitespace in the URL and no nested brackets in the
// alt text, so a line of prose that merely contains brackets and parentheses
// is never mistaken for an image.
const MARKDOWN_IMAGE_PATTERN = /!\[([^\]]*)\]\(([^)\s]+)\)/g;
/**
* Splits a markdown body into plain-text runs and the image links between
* them, so a renderer can turn `` into an actual `<img>` while
* leaving everything else (mentions included) to the existing text handling.
*/
export const splitMarkdownImages = (body: string): MarkdownImageSegment[] => {
if (!body) return [{ type: 'text', value: body }];
const segments: MarkdownImageSegment[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
MARKDOWN_IMAGE_PATTERN.lastIndex = 0;
while ((match = MARKDOWN_IMAGE_PATTERN.exec(body)) !== null) {
const [full, alt, url] = match;
if (match.index > lastIndex) {
segments.push({
type: 'text',
value: body.slice(lastIndex, match.index),
});
}
segments.push({ type: 'image', value: alt, url });
lastIndex = match.index + full.length;
}
if (lastIndex < body.length) {
segments.push({ type: 'text', value: body.slice(lastIndex) });
}
return segments;
};
This is a deliberately tiny parser, not a markdown renderer: comments
are otherwise plain text with mention tokens, and pulling in
react-markdown for them would change how every existing comment
renders. Anything that isn't an image link stays untouched text.
Part A — Issue comments (built)
Step 2 — Props and the TextArea atom
onImageUpload?: (file: File) => Promise<string> is added, in
resources/js/types/Components.ts, to CommentFormProps,
CommentListProps, CommentItemProps and EditableTextProps —
optional everywhere, so a surface that leaves it out simply has no
uploads.
TextArea is a controlled atom that forwards only the handlers it
declares, so drop had to be added to it:
onPaste,
onCut,
onBlur,
onDrop,
...and passed through to the <textarea>. onPaste was already
forwarded.
Step 3 — Composing a comment: insert, and keep the mention ranges honest
File: resources/js/Components/Molecules/CommentForm/CommentForm.tsx
This is where a comment differs from every other surface, and it is the part that silently corrupts data if you skip it.
CommentForm tracks each @mention as a character range into
the body (mentionRanges) and reconciles those ranges against every
edit through applyRangeEdit(prev, start, end, insertedLength). The
ranges are what tokenizeMentionRanges() uses at submit time to turn
display names back into user ids, so an insertion that skips the
reconciliation shifts every mention after it and the comment
notifies the wrong person — with nothing visibly wrong on screen.
There is a second trap in the same component: handleChange drops
all tracked ranges whenever a change arrives without a captured
edit range (see its comment about IME, drag-and-drop and undo). A
programmatic setBody() never reaches handleChange at all, so the
reconciliation is done by hand, inside the functional update:
/**
* Uploads a batch one at a time and moves the insertion point past each
* image as it lands, so several files pasted at once keep their order
* instead of every one of them splicing into the original range.
*/
const insertImages = async (
files: File[],
range: { start: number; end: number },
) => {
if (!onImageUpload) return;
let target = range;
for (const file of files) {
const at = target;
try {
const url = await onImageUpload(file);
setBody((current) => {
const result = insertMarkdownImage(current, at, file, url);
setMentionRanges((prev) =>
applyRangeEdit(prev, at.start, at.end, result.length),
);
setPendingCaret(result.caret);
return result.body;
});
target = nextImageRange(at, file, url);
} catch {
// The uploader already reported the failure to the user; the
// remaining files in this batch still get their turn.
}
}
};
setPendingCaret is the component's existing mechanism — the
useEffect watching [body, pendingCaret] re-focuses the textarea
and restores the selection after the re-render.
The paste handler already existed (its only job was
captureEditRange), so it is extended rather than duplicated:
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const files = extractImageFiles(e.clipboardData);
if (onImageUpload && files.length > 0) {
e.preventDefault();
const { selectionStart: start, selectionEnd: end } =
e.currentTarget;
// Consumed by the insertion below, not by handleChange - which never
// runs for a paste we've prevented.
editRangeRef.current = null;
void insertImages(files, { start, end });
return;
}
captureEditRange(e.currentTarget);
};
const handleDrop = (e: React.DragEvent<HTMLTextAreaElement>) => {
const files = extractImageFiles(e.dataTransfer);
if (!onImageUpload || files.length === 0) return;
e.preventDefault();
const caret = e.currentTarget.selectionStart;
void insertImages(files, { start: caret, end: caret });
};
with onDrop={handleDrop} on the TextArea. Falling through to
captureEditRange when there is no uploader or no image is what
keeps an ordinary text paste behaving exactly as before.
A drop onto a textarea does not move the caret first, so the
insertion point is wherever the caret already was — unlike the Tiptap
surface, there is no posAtCoords() equivalent worth reaching for.
Upload the batch sequentially, not with files.forEach. Firing
the uploads in parallel and letting each one splice independently
looks fine — every setBody/setMentionRanges call is a functional
update reading the latest state — but the range each one captured
is the original. Two pasted images then both insert at the same
point, so they land in reverse order, and over a selection the second
one cuts through the markdown the first already wrote. Awaiting each
file in turn and advancing through nextImageRange() is what keeps
them in order. Still never hoist body into the closure: the splice
has to read the latest state, since the user can type between
uploads.
Step 4 — Editing an existing comment
File: resources/js/Components/Atoms/EditableText/EditableText.tsx
An existing comment is edited through EditableText in multiline
mode, so the upload support lives in that atom rather than in
CommentItem — which also means any other multiline EditableText
gets it by passing one prop.
const insertImages = async (
files: File[],
range: { start: number; end: number },
) => {
if (!onImageUpload) return;
pendingUploadsRef.current += files.length;
let target = range;
for (const file of files) {
const at = target;
try {
const url = await onImageUpload(file);
setDraft((current) => {
const result = insertMarkdownImage(current, at, file, url);
setPendingCaret(result.caret);
return result.body;
});
target = nextImageRange(at, file, url);
} catch {
// The uploader already reported the failure to the user.
} finally {
pendingUploadsRef.current = Math.max(
0,
pendingUploadsRef.current - 1,
);
}
}
};
handlePaste/handleDrop are the same shape as CommentForm's,
minus the mention bookkeeping, and are wired onto the multiline
TextArea next to the existing onBlur={commit}. Note the counter
is raised by the whole batch up front, so the blur guard below holds
for the entire sequence rather than only for the file in flight.
The gotcha, identical to the Tiptap one: EditableText commits
on blur. Losing focus mid-upload would run commit(), leave edit
mode and save the draft as it was — and the image would then be
spliced into a draft nobody is editing and never persisted. Hence:
const commit = () => {
if (pendingUploadsRef.current > 0) return;
setIsEditing(false);
if (draft !== value) {
onSave(draft);
}
};
A ref, not state, because commit is the handler the textarea
already holds.
CommentList and CommentItem do nothing but pass onImageUpload
down — Pages/Issues/Show.tsx hands the same uploadImage from
useImageUpload(project.id) to CommentList and CommentForm that
it already gives the description editor.
Step 5 — Rendering the image in the posted comment
File: resources/js/Components/Molecules/CommentItem/CommentItem.tsx
Without this step everything above stores correctly and displays as
literal . The body is rendered in two
passes: images first, then the existing mention splitting over each
text run between them.
const renderBody = (value: string) =>
splitMarkdownImages(value).map((segment, index) =>
segment.type === 'image' ? (
// Stops the click from reaching EditableText's edit-on-click
// wrapper - clicking a picture opens it, it doesn't start an
// edit the way clicking the text around it does.
<a
key={index}
href={segment.url}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="my-1 block w-fit"
>
<img
src={segment.url}
alt={segment.value}
className="max-h-80 max-w-full rounded-lg border border-[var(--border-color)]"
/>
</a>
) : (
renderText(segment.value, String(index))
),
);
renderText is the previous renderBody body, unchanged except for
a key prefix — mentions keep rendering exactly as they did, including
inside the text that surrounds an image.
The stopPropagation matters because the comment body is a click
target: EditableText starts an edit when its display area is
clicked, and without it a click on the picture would open the
textarea instead of the image.
Step 6 — Nothing to change on the backend
CommentController::store() and update() keep validating body as
required|string; the image is already stored and the body just
contains a markdown link to it. App\Policies\CommentPolicy is
untouched too — the upload was authorized as "can view this project"
when it happened, and posting or editing the comment is authorized
separately as it always was.
Part B — Issue type templates (built)
A template's description is the markdown a new issue of that type
starts from (see
../issue-types/README.mdIssue TypesIssue types are a per-project resource backed by a real issuetypes database table (App\Models\IssueType) — not a fixed backend enum, and not a plain string…), which means
an image pasted into a template shows up in every issue created from
it — the attachment is written once and referenced from every one of
those descriptions. That is fine by design: attachments are owned by
the project, not by the issue, so no issue "owns" the file and
deleting one issue can never break another's image.
Two things make this the simpler of the two surfaces, and they are what to look for when judging a new one:
- Rendering is somebody else's problem. A template body is only
ever displayed after it has been copied into an issue description,
and that is rendered by
EditableMarkdown— Tiptap draws the image natively. Nothing here needssplitMarkdownImages. - There are no mentions, so no range bookkeeping: the splice is
just
setDescription.
Step B1 — Upload from the modal
File: resources/js/Components/Organisms/WorkspaceSettingsContent/WorkspaceSettingsTemplatesModal.tsx
The modal already receives projectId and already uses useAlert,
so the hook drops straight in — note both go above the
if (!issueType) return null; early return, like every other hook in
the component:
const { uploadImage } = useImageUpload(projectId);
const descriptionRef = useRef<HTMLTextAreaElement>(null);
The ref is new: unlike CommentForm this component holds no
textarea ref today, and the caret has to be restored after the
splice.
Step B2 — Paste and drop on the description textarea
const insertImages = async (
files: File[],
range: { start: number; end: number },
) => {
let target = range;
for (const file of files) {
const at = target;
try {
const url = await uploadImage(file);
setDescription((current) => {
const result = insertMarkdownImage(current, at, file, url);
// This component has no pendingCaret effect the way
// CommentForm does, and needs none for a single field - the
// textarea is still mounted, it just lost its selection to
// the re-render.
requestAnimationFrame(() => {
descriptionRef.current?.focus();
descriptionRef.current?.setSelectionRange(
result.caret,
result.caret,
);
});
return result.body;
});
target = nextImageRange(at, file, url);
} catch {
// The uploader already reported the failure to the user.
}
}
};
const handleDescriptionPaste = (
e: React.ClipboardEvent<HTMLTextAreaElement>,
) => {
const files = extractImageFiles(e.clipboardData);
if (files.length === 0) return;
e.preventDefault();
const { selectionStart: start, selectionEnd: end } = e.currentTarget;
void insertImages(files, { start, end });
};
const handleDescriptionDrop = (e: React.DragEvent<HTMLTextAreaElement>) => {
const files = extractImageFiles(e.dataTransfer);
if (files.length === 0) return;
e.preventDefault();
const caret = e.currentTarget.selectionStart;
void insertImages(files, { start: caret, end: caret });
};
and on the existing description field:
<TextArea
ref={descriptionRef}
value={description}
onChange={(e) => setDescription(e.target.value)}
onPaste={handleDescriptionPaste}
onDrop={handleDescriptionDrop}
placeholder="Description every new issue of this type starts from"
variant="modal"
className="min-h-[120px] font-mono text-xs"
/>
The requestAnimationFrame stands in for CommentForm's
pendingCaret effect — this component has no such mechanism and
doesn't need a general one for a single field.
uploadImage already toasts and re-throws on failure, so the
catch here is empty on purpose and insertImages's promise is left
unawaited (void): a rejected upload has already been surfaced to
the user, the description is left exactly as it was, and the rest of
the batch still gets its turn.
There is no blur guard here, unlike EditableText's: this form is
saved by an explicit "Add template"/"Save" button, so losing focus
mid-upload commits nothing.
Step B3 — Guard it behind the existing permission
The modal renders the form only when canManageTemplates is true,
which is the frontend mirror of
$this->authorize('updateIssueTypes', $project) in
IssueTypeTemplateController. Keep the paste/drop handlers inside
that same conditional block — the upload endpoint only checks project
membership, so a member who is not allowed to manage templates must
not be given a textarea to paste into in the first place.
Step B4 — Nothing to change on the backend
IssueTypeTemplateController keeps validating description as
nullable|string, and
IssueTypeTemplateService::createTemplate()/updateTemplate() are
untouched. An issue created from the template copies the markdown
verbatim, image links and all.
Tests
Part A's coverage already exists and is what a new surface should be modelled on:
resources/js/utils/imagePaste.test.ts—insertMarkdownImageat a collapsed caret, over a selected range and on an empty body, plus a round-trip asserting what it produces is whatsplitMarkdownImagesparses back;markdownImageAltdropping the link-breaking characters and falling back toimagewhen nothing usable is left;nextImageRangeagreeing with the caretinsertMarkdownImagereports and chaining a batch in order; andsplitMarkdownImagesitself: no image, empty body, text around an image, two consecutive images with an empty alt, and the two negative cases that keep the parser honest (a plain[link](url)and prose that merely contains brackets).resources/js/Components/Molecules/CommentForm/CommentForm.test.tsx— paste uploads and submits the markdown link; the mention regression: with a mention already selected, paste an image before it and assert the submitted body still carries@[Jane Cooper](1)andmentioned_user_idsstill carries that id; drop at the caret; pasting two images at once landing in order, over a selection replacing it exactly once, and a failed first upload not stopping the second; and paste left to the browser both when there is no uploader and when the clipboard holds no image (defaultPrevented === false).resources/js/Components/Atoms/EditableText/EditableText.test.tsx— paste at the caret, paste replacing a selection, drop, a failed upload leaving the draft untouched, paste without an uploader, and blur during an in-flight upload neither committing nor losing the image.resources/js/Components/Molecules/CommentItem/CommentItem.test.tsx— a body with an image renders an<img>and not the literal markdown, the image links to the file without starting an edit, and mentions still render alongside it.resources/js/Components/Molecules/CommentList/CommentList.test.tsx— one integration case proving the prop actually reaches the edit textarea, sinceCommentList/CommentItemonly pass it through.tests/Feature/CommentControllerTest.php— storing and editing a body containinground-trips unchanged. That is all the backend needs: no new route, no new validation, no new service.
Part B's equivalents, thinner because there is no rendering and no mention bookkeeping to protect:
resources/js/Components/Organisms/WorkspaceSettingsContent/WorkspaceSettingsTemplatesModal.test.tsx— mock@/hooks/useImageUpload(the real one callsaxiosandroute()), then assert: pasting inserts the markdown link androuter.postreceives thatdescription; drop inserts at the caret; a failed upload leaves the description untouched; a paste with no image is left to the browser; and, for the permission gate, thatcanManageTemplates: falserenders no description field at all.tests/Feature/IssueTypeTemplateControllerTest.php— creating and updating a template whosedescriptioncontainsround-trips unchanged.
