Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 | 4x 4x 4x 3x 4x 4x 2x 35x 35x 35x 35x 35x 3x 3x 2x 1x 1x 1x 1x 1x 35x 25x 35x 25x 22x 35x 1x 34x 35x 5x 5x 35x 9x 4x 4x 5x 2x 2x 2x 2x 2x 3x 3x 3x 3x 1x 1x 35x 8x 2x 35x 3x 1x 1x 1x 2x 2x 35x 4x 3x 3x 3x 1x 1x 1x 1x 1x 3x 3x 3x 3x 3x 3x 3x 1x 1x 2x 3x 35x 35x 3x 3x 3x 3x 3x 1x 1x 2x 2x 2x 2x 35x 4x 3x 1x 4x 35x 1x 1x 2x 1x 1x 1x 1x 35x | import { useState, useEffect, useRef, type MutableRefObject } from "react";
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import { cn } from "../../lib/utils";
import { notify } from "../../lib/notify";
import { ApiError, adminApi, getImageUrl, proApi } from "../../lib/api";
import { uploadImage } from "../../lib/upload";
import {
EditorToolbar,
type CopilotAction,
type LayoutBlockKind,
} from "./EditorToolbar";
import { ImageBubbleMenu } from "./ImageBubbleMenu";
import { ImageGalleryModal } from "./ImageGalleryModal";
import { layoutBlockExtensions } from "./nodes/layout-blocks";
import "./blog-content.css";
/**
* Pull image files out of a paste/drop payload. Prefers `files` (the common
* case — a copied image file or a drag from Finder), falling back to `items`
* for clipboards that only expose the file there (e.g. some screenshot tools).
*/
function extractImageFiles(dt: DataTransfer | null): File[] {
Iif (!dt) return [];
const out: File[] = [];
for (const file of Array.from(dt.files ?? [])) {
Eif (file.type.startsWith("image/")) out.push(file);
}
Iif (out.length === 0 && dt.items) {
for (const item of Array.from(dt.items)) {
if (item.kind === "file" && item.type.startsWith("image/")) {
const file = item.getAsFile();
if (file) out.push(file);
}
}
}
return out;
}
type ImagePick = { url: string; alt: string };
/** A pending request for N images, with a builder that consumes them once collected. */
type ImageRequest = {
count: number;
collected: ImagePick[];
build: (images: ImagePick[]) => void;
};
const CustomImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
style: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute("style"),
renderHTML: (attributes: Record<string, unknown>) => {
if (!attributes.style) return {};
return { style: attributes.style };
},
},
};
},
});
interface RichTextEditorProps {
content: string;
onChange: (html: string) => void;
placeholder?: string;
proId?: string;
blogId?: string;
/** "admin" lets the image picker open without an owning pro (admin browses all pros). */
mode?: "pro" | "admin";
/**
* Persist the draft on demand and return its real id. When the editor is
* mounted before the blog exists (blogId="new"), the image picker calls this
* first so the gallery — including its local Upload tab — can open instead of
* falling back to a URL prompt. Return null to abort (e.g. title required).
*/
onEnsureBlogId?: () => Promise<string | null>;
editable?: boolean;
className?: string;
/** Called when user triggers an AI action from the toolbar */
onAIAction?: (
action: CopilotAction,
selection: string,
from: number,
to: number,
) => void;
/** Whether an AI action is currently loading */
aiLoading?: boolean;
/** Ref to access the TipTap editor instance from parent */
editorRef?: MutableRefObject<Editor | null>;
}
export function RichTextEditor({
content,
onChange,
placeholder = "Start writing your blog...",
proId,
blogId,
mode = "pro",
onEnsureBlogId,
editable = true,
className,
onAIAction,
aiLoading,
editorRef,
}: RichTextEditorProps) {
const [showImageGallery, setShowImageGallery] = useState(false);
// Pending multi-image request (e.g. a 2-column grid needs 2 images). The
// gallery modal returns one image per pick; we buffer them here until the
// builder has enough, then insert the block.
const imageRequestRef = useRef<ImageRequest | null>(null);
// True for the instant between the modal's onInsertImage and its onClose, so
// we can tell a pick-driven close (keep collecting) from a manual close
// (cancel the request).
const justPickedRef = useRef(false);
// editorProps is captured once at editor creation, so its paste/drop handlers
// must reach current state through refs rather than closed-over props. This
// ref always points at the latest paste/drop handler (assigned each render).
const handleImageFilesRef = useRef<
(files: File[], insertPos: number | null) => boolean
>(() => false);
const editor = useEditor({
extensions: [
StarterKit.configure({
link: false,
}),
CustomImage.configure({
HTMLAttributes: {
class: "max-w-full h-auto rounded-lg cursor-pointer",
},
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: "text-primary-600 underline",
},
}),
Placeholder.configure({
placeholder,
}),
...layoutBlockExtensions,
],
content,
editable,
editorProps: {
attributes: {
class: "blog-content focus:outline-none min-h-[400px] p-4",
},
// Pasting/dropping an image FILE (a screenshot, a copied image, a drag
// from Finder) uploads it to R2 and inserts it — same path as the
// gallery's Upload tab. Anything without an image file (text, HTML,
// internal node moves) falls through to ProseMirror's default.
handlePaste: (_view, event) => {
const files = extractImageFiles(event.clipboardData);
return handleImageFilesRef.current(files, null);
},
handleDrop: (view, event, _slice, moved) => {
if (moved) return false; // dragging an existing node — let PM move it
const files = extractImageFiles(event.dataTransfer);
Iif (files.length === 0) return false;
const at = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
return handleImageFilesRef.current(files, at ? at.pos : null);
},
},
onUpdate: ({ editor: e }) => {
onChange(e.getHTML());
},
});
// Expose editor instance to parent via effect (not during render)
useEffect(() => {
if (editorRef) editorRef.current = editor;
}, [editor, editorRef]);
// Disable editing while AI is loading to prevent stale cursor positions
useEffect(() => {
if (editor && editable) {
editor.setEditable(!aiLoading);
}
}, [editor, aiLoading, editable]);
if (!editor) {
return null;
}
// The gallery needs a PERSISTED blog to upload/reference images against. The
// admin "create from scratch" screen mounts the editor with blogId="new"
// before the draft exists — opening the gallery then would POST to a blog
// that doesn't exist. Until the draft is saved, fall back to URL prompts.
const galleryReady =
!!blogId && blogId !== "new" && (!!proId || mode === "admin");
// Collect `count` images, then hand them to `build`. Prefers the gallery
// modal (with its local Upload tab). If the blog isn't persisted yet but the
// host can create it on demand (onEnsureBlogId), persist first so the gallery
// can open. Only when there's no persistence path at all do we fall back to
// URL prompts.
const openGallery = (count: number, build: (images: ImagePick[]) => void) => {
imageRequestRef.current = { count, collected: [], build };
setShowImageGallery(true);
};
const requestImages = async (
count: number,
build: (images: ImagePick[]) => void,
) => {
if (galleryReady) {
openGallery(count, build);
return;
}
if (onEnsureBlogId) {
// Persist the draft so the gallery (and local Upload) can open. On the
// next render blogId is real, galleryReady is true, and the modal mounts.
let ensured: string | null = null;
try {
ensured = await onEnsureBlogId();
} catch {
ensured = null;
}
// Aborted (e.g. a title is still required) — the host already surfaced
// why; don't silently degrade to a URL prompt.
if (ensured) openGallery(count, build);
return;
}
const images: ImagePick[] = [];
for (let i = 0; i < count; i++) {
const url = window.prompt(
count > 1
? `Enter image URL (${i + 1} of ${count}):`
: "Enter image URL:",
);
if (!url) return; // cancelled — insert nothing
images.push({ url, alt: "" });
}
build(images);
};
const handleImageClick = () => {
requestImages(1, ([img]) => {
editor.chain().focus().setImage({ src: img.url, alt: img.alt }).run();
});
};
// Upload a single image file through the same path the gallery Upload tab
// uses (pro → lib/upload.ts honours presigned/worker mode; admin → adminApi),
// returning an insertable {url, alt} or null on failure.
const uploadImageFile = async (
file: File,
persistedBlogId: string,
): Promise<ImagePick | null> => {
if (mode === "admin") {
const res = await adminApi.uploadAdminBlogImage(persistedBlogId, file);
Iif (!res.data) return null;
return { url: proApi.getBlogImageUrl(res.data), alt: res.data.altText ?? "" };
}
const result = await uploadImage(proId as string, file, {
type: "blog-image",
id: persistedBlogId,
});
return { url: getImageUrl(result.storageKey), alt: "" };
};
// Handle image files from a paste or drop: ensure the blog is persisted (so
// there's somewhere to upload to), upload each file, and insert it. Returns
// true synchronously when image files were present so ProseMirror skips its
// default handling (which would hotlink an external URL or drop the file).
const handleImageFiles = (
files: File[],
insertPos: number | null,
): boolean => {
if (files.length === 0) return false;
void (async () => {
let persistedBlogId = blogId;
if (!galleryReady) {
// Blog not saved yet (e.g. admin "create from scratch"). Persist it
// first, exactly like the gallery does, so uploads have a home.
Iif (!onEnsureBlogId) {
notify.error("Save the blog before adding images.");
return;
}
try {
const ensured = await onEnsureBlogId();
Iif (!ensured) return; // host already surfaced why (e.g. title required)
persistedBlogId = ensured;
} catch {
notify.error("Couldn't save the blog to upload the image.");
return;
}
}
Iif (!persistedBlogId) return;
let pos = insertPos;
for (const file of files) {
try {
const pick = await uploadImageFile(file, persistedBlogId);
Iif (!pick) continue;
if (pos != null) {
editor
.chain()
.focus()
.insertContentAt(pos, {
type: "image",
attrs: { src: pick.url, alt: pick.alt },
})
.run();
// Resume after the just-inserted node. Inserting a block image
// mid-paragraph splits the paragraph, so the cursor advances by
// more than the node's own size — read the real position from the
// selection rather than assuming a fixed +1.
pos = editor.state.selection.to;
} else {
editor
.chain()
.focus()
.setImage({ src: pick.url, alt: pick.alt })
.run();
}
} catch (err) {
notify.error(
err instanceof ApiError
? `Failed to upload image: ${err.message}`
: "Failed to upload image. Please try again.",
);
}
}
})();
return true;
};
// Keep the editorProps closure pointing at the current handler each render.
handleImageFilesRef.current = handleImageFiles;
// Called by the gallery modal once per picked image.
const handleInsertImage = (image: { url: string; alt: string }) => {
justPickedRef.current = true;
const req = imageRequestRef.current;
Iif (!req) {
editor.chain().focus().setImage({ src: image.url, alt: image.alt }).run();
setShowImageGallery(false);
return;
}
req.collected.push(image);
if (req.collected.length < req.count) {
// Need more — the modal self-closes after each pick, so reopen it on
// the next tick to collect the remaining images.
setTimeout(() => setShowImageGallery(true), 0);
return;
}
const { collected, build } = req;
imageRequestRef.current = null;
build(collected);
setShowImageGallery(false);
};
// The modal closes after every pick; only a close that did NOT follow a pick
// is a real cancel, which clears any in-flight request.
const handleGalleryClose = () => {
if (justPickedRef.current) {
justPickedRef.current = false;
} else {
imageRequestRef.current = null;
}
setShowImageGallery(false);
};
const handleInsertBlock = (kind: LayoutBlockKind) => {
const insert = (content: Record<string, unknown>) =>
editor.chain().focus().insertContent(content).run();
const toAttr = (i: ImagePick) => ({ src: i.url, alt: i.alt });
switch (kind) {
case "tip-card":
insert({
type: "tipCard",
content: [
{
type: "paragraph",
content: [
{ type: "text", marks: [{ type: "bold" }], text: "Tip: " },
],
},
],
});
break;
case "hero":
requestImages(1, ([img]) =>
insert({ type: "heroImage", attrs: toAttr(img) }),
);
break;
case "captioned":
requestImages(1, ([img]) =>
insert({ type: "captionedImage", attrs: toAttr(img) }),
);
break;
case "grid-2":
requestImages(2, (imgs) =>
insert({
type: "imageGrid",
attrs: { columns: 2, images: imgs.map(toAttr) },
}),
);
break;
case "grid-3":
requestImages(3, (imgs) =>
insert({
type: "imageGrid",
attrs: { columns: 3, images: imgs.map(toAttr) },
}),
);
break;
case "compare":
requestImages(2, ([before, after]) =>
insert({
type: "imageCompare",
attrs: {
before: { ...toAttr(before), label: "Before" },
after: { ...toAttr(after), label: "After" },
},
}),
);
break;
}
};
return (
<div className={cn("border border-border-default rounded-lg", className)}>
{editable && (
<EditorToolbar
editor={editor}
onImageClick={handleImageClick}
onInsertBlock={handleInsertBlock}
onAIAction={onAIAction}
aiLoading={aiLoading}
/>
)}
<EditorContent editor={editor} className="bg-background-elevated" />
{editable && <ImageBubbleMenu editor={editor} />}
{galleryReady && blogId && (
<ImageGalleryModal
isOpen={showImageGallery}
mode={mode}
proId={proId}
blogId={blogId}
onClose={handleGalleryClose}
onInsertImage={handleInsertImage}
/>
)}
</div>
);
}
|