Add image insertion and editing to docs editor (Phase 1)#121
Conversation
Delivers the first phase of Google Docs–style image support: toolbar insert (upload / URL), drag-and-drop, clipboard paste, click-to-select with 8-handle overlay, aspect-ratio-locked resize with drag shadow + dimension HUD, keyboard nudge, and Delete/Esc. Key changes across four milestones: Milestone 1 — Data model & editor API - ImageData gains rotation, crop*, originalWidth/Height fields - EditorAPI: insertImage, selectImageAt, clearImageSelection, getSelectedImage, updateSelectedImage - findImageAtOffset / clampImageToWidth helpers Milestone 2 — Selection state & overlay rendering - image-selection-overlay.ts: drawImageSelection, collectImageRects, hitTestImageHandle/Rect, findImageAtPoint, cursorForHandle - DocCanvas: imageSelectionRect param, caret suppression when active - Capture-phase mousedown for image click-to-select - TextEditor: imageKeyHandler for Delete/Backspace/Esc routing Milestone 3 — Resize interaction - computeResizeDelta (dominant-axis aspect lock) + computePreviewRect - Drag state machine: mousedown → document mousemove/up → single undo-step commit on mouseup - Keyboard nudge (arrows ±1, Shift ±8), proportional scaling - Cursor hints via TextEditor.imageHoverHandler - Drag-lift shadow: committed run skipped, preview drawn with ctx.shadowBlur=16 drop shadow Milestone 4 — Insert flows - Toolbar InsertImageDropdown: Upload from computer + By URL - image-insert.ts: uploadImageFile, loadImageDimensions, insertImageFromFile, insertImageFromUrl - EditorAPI.onImageFileDrop callback for DnD + clipboard paste - TextEditor.imageFilePasteHandler intercepts image/* files - insertImage auto-clamps to page content width Bug fixes applied during development: - Double-margin offset in collectImageRects (pl.x already includes margins.left) - TextEditor mousemove always reset cursor to 'text', overriding handle hover hints - loadImageDimensions crossOrigin="anonymous" broke non-CORS hosts - InsertImageDropdown urlMode not reset on programmatic close - Table cell images now selectable (non-merged, top-aligned cells) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 1 minutes and 38 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (8)
📝 WalkthroughWalkthroughAdds Phase 1 inline image insertion and editing: new ImageData fields (rotation/crop/original dims), EditorAPI image methods, image selection/resize overlay with eight handles, rendering/hit-testing/resize math, toolbar/drag‑drop/paste insertion flows, and accompanying tests and docs. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant Frontend as Frontend UI<br/>(Toolbar / Upload)
participant Editor as Editor<br/>(EditorAPI)
participant Model as Data Model<br/>(types.ts)
participant Renderer as Renderer<br/>(DocCanvas)
rect rgba(100, 150, 255, 0.5)
Note over User,Frontend: Image insertion path
User->>Frontend: Upload / Paste / Enter URL / Drag-drop
Frontend->>Frontend: uploadImageFile() / loadImageDimensions()
Frontend->>Editor: insertImage(url, w, h, opts)
Editor->>Model: create & insert ImageData
Editor->>Renderer: requestRender()
Renderer-->>User: render inline image
end
rect rgba(150, 200, 100, 0.5)
Note over User,Editor: Selection & resize flow
User->>Renderer: Click image / Pointer down on handle
Renderer->>Editor: hitTest → selectImageAt()
Editor->>Renderer: compute imageSelectionRect & requestRender()
Renderer-->>User: draw selection overlay with 8 handles
User->>Editor: drag handle (mousemove)
Editor->>Editor: computeResizeDelta() / computePreviewRect()
Editor->>Renderer: requestRender(previewRect, dragRun)
Renderer-->>User: show drag preview + shadow
User->>Editor: mouseup
Editor->>Model: commit updateSelectedImage(patch) (single undo)
Editor->>Renderer: requestRender()
Renderer-->>User: render updated image
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~55 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Verification: verify:selfResult: ✅ PASS in 114.7s
Verification: verify:integrationResult: ✅ PASS |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (2)
packages/docs/src/model/types.ts (1)
274-287: Consider guarding against non-positivemaxWidth.The function handles
width <= 0defensively, but ifmaxWidthis zero or negative (e.g., miscalculated content area), the scale computation would produce invalid dimensions. This is an edge case, but adding a similar guard formaxWidthwould make the function fully defensive.🛡️ Optional defensive fix
export function clampImageToWidth( width: number, height: number, maxWidth: number, ): { width: number; height: number } { - if (width <= maxWidth || width <= 0) { + if (width <= maxWidth || width <= 0 || maxWidth <= 0) { return { width, height }; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/src/model/types.ts` around lines 274 - 287, In clampImageToWidth, add a guard for non-positive maxWidth (e.g., if maxWidth <= 0) to avoid dividing by zero or producing invalid scales; return the original { width, height } (same behavior as the existing width <= 0 case) or normalize maxWidth to a safe minimum (Math.max(1, maxWidth)) before computing scale so the function never computes an invalid scale.packages/docs/src/view/editor.ts (1)
2077-2093: Consider clearing image-related state indispose()to prevent stale references.When
dispose()is called during an active resize drag,imageResizeDragretains references to DOM measurement data and the callbackimageFileDropCallbackmay still hold a closure. While not a functional bug (the listeners are removed), explicitly clearing this state would be more defensive.🧹 Proposed cleanup additions
dispose: () => { peerCursors = []; cursorMoveCallback = null; lastPeerPixels = []; + selectedImage = null; + imageResizeDrag = null; + imageFileDropCallback = null; ruler.dispose(); cursor.dispose(); textEditor?.dispose();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/docs/src/view/editor.ts` around lines 2077 - 2093, In dispose() add explicit cleanup for image-related state: clear/reset the imageResizeDrag object (or set imageResizeDrag = null) to drop any DOM measurement refs and any drag state, and set imageFileDropCallback = null (and any other image-related callback vars) to remove closure references; keep the rest of the existing listener removals and resource disposals (ruler, cursor, textEditor, resizeObserver, canvas) intact. Ensure you reference the same symbols used in this file (dispose, imageResizeDrag, imageFileDropCallback) so the garbage collector can reclaim DOM-related memory when the editor is torn down.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/design/docs/docs-image-editing.md`:
- Around line 83-90: The docs for insertImage are out of sync: update the
documented signature and behavior to match the actual implementation's API
(insertImage(src, width, height, opts?) in packages/docs/src/view/editor.ts) and
note that it inserts at the caret (does not replace non-collapsed selections)
rather than replacing selections; adjust parameter names (naturalWidth→width,
naturalHeight→height, alt→opts) and describe the opts structure if applicable so
tests and callers aren't misled.
- Around line 148-179: Update the docs to reflect the actual rollout phases:
change any language in the "Floating Context Bar" and "Image Options Side Panel"
sections (and the other occurrence around lines noted) that claims these
features "ship in Phase 1" to indicate they are planned for later milestones
(Milestones 5 and 6) or mark them as Phase 2+ work; ensure the doc explicitly
references that these features are out-of-scope for Phase 1, mention that they
are pending in Milestones 5/6, and keep the feature descriptions but add a short
"Planned in later milestones" note under each heading.
In `@docs/tasks/active/20260412-docs-image-editing-todo.md`:
- Around line 129-132: Update the By-URL path note for
insertImageFromUrl(editor, url) to reflect that the preflight no longer forces
crossOrigin="anonymous"; instead validate the http[s]:// prefix, perform a
lightweight preflight/load without setting crossOrigin so we don't force CORS,
and document that natural dimensions are used and that callers should handle any
CORS requirements if they need cross-origin tainting protection.
In `@packages/frontend/src/app/docs/docs-formatting-toolbar.tsx`:
- Around line 147-152: The current handleUrlSubmit closes and clears the URL
form (closeAndReset) before insertImageFromUrl completes, losing user input on
failure; change handleUrlSubmit to await insertImageFromUrl(editor, url) and
only call closeAndReset() if that call returns a success true (or does not
throw), otherwise leave the form open and surface the error; update the
insertImageFromUrl helper to return a boolean success flag (or throw on fatal
errors) instead of swallowing failures so handleUrlSubmit can decide whether to
close the form; ensure you reference handleUrlSubmit, insertImageFromUrl,
closeAndReset, urlInput and editor when making these changes.
In `@packages/frontend/src/app/docs/image-insert.ts`:
- Around line 78-111: insertImageFromUrl currently persists third‑party
hotlinked URLs; change it to fetch/proxy and store the image on our trusted
storage before calling editor.insertImage so viewers won't hotlink external
hosts. Keep the existing validation and loadImageDimensions call to get
width/height, then call a new or existing helper (e.g. uploadImageFromUrl or
proxyFetchImage) to download and upload the remote image to first‑party storage
or return a proxied URL, and pass that returned hosted URL (not the original
trimmed URL) into EditorAPI.insertImage while preserving
originalWidth/originalHeight metadata and error handling.
---
Nitpick comments:
In `@packages/docs/src/model/types.ts`:
- Around line 274-287: In clampImageToWidth, add a guard for non-positive
maxWidth (e.g., if maxWidth <= 0) to avoid dividing by zero or producing invalid
scales; return the original { width, height } (same behavior as the existing
width <= 0 case) or normalize maxWidth to a safe minimum (Math.max(1, maxWidth))
before computing scale so the function never computes an invalid scale.
In `@packages/docs/src/view/editor.ts`:
- Around line 2077-2093: In dispose() add explicit cleanup for image-related
state: clear/reset the imageResizeDrag object (or set imageResizeDrag = null) to
drop any DOM measurement refs and any drag state, and set imageFileDropCallback
= null (and any other image-related callback vars) to remove closure references;
keep the rest of the existing listener removals and resource disposals (ruler,
cursor, textEditor, resizeObserver, canvas) intact. Ensure you reference the
same symbols used in this file (dispose, imageResizeDrag, imageFileDropCallback)
so the garbage collector can reclaim DOM-related memory when the editor is torn
down.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 2508f4ae-8288-43f8-b0b3-ac67914b0095
📒 Files selected for processing (16)
docs/design/README.mddocs/design/docs/docs-image-editing.mddocs/tasks/README.mddocs/tasks/active/20260412-docs-image-editing-lessons.mddocs/tasks/active/20260412-docs-image-editing-todo.mdpackages/docs/src/model/types.tspackages/docs/src/view/doc-canvas.tspackages/docs/src/view/editor.tspackages/docs/src/view/image-selection-overlay.tspackages/docs/src/view/table-renderer.tspackages/docs/src/view/text-editor.tspackages/docs/test/model/types.test.tspackages/docs/test/view/image-selection-overlay.test.tspackages/frontend/src/app/docs/docs-formatting-toolbar.tsxpackages/frontend/src/app/docs/docs-view.tsxpackages/frontend/src/app/docs/image-insert.ts
| /** | ||
| * Validate + insert an image URL typed by the user in the toolbar. | ||
| * Unlike `insertImageFromFile`, this skips the upload step — the URL | ||
| * is inserted as-is so external images (e.g. `https://...` referenced | ||
| * from another host) do not get re-uploaded. The preflight load still | ||
| * happens so we capture the natural dimensions and fail early on 404 / | ||
| * CORS errors. | ||
| */ | ||
| export async function insertImageFromUrl( | ||
| editor: EditorAPI, | ||
| url: string, | ||
| ): Promise<void> { | ||
| const trimmed = url.trim(); | ||
| if (!trimmed) return; | ||
| if (!/^https?:\/\//i.test(trimmed)) { | ||
| toast.error("Image URL must start with http:// or https://"); | ||
| return; | ||
| } | ||
| try { | ||
| const { width, height } = await loadImageDimensions(trimmed); | ||
| editor.insertImage(trimmed, width, height, { | ||
| originalWidth: width, | ||
| originalHeight: height, | ||
| }); | ||
| editor.focus(); | ||
| } catch (err) { | ||
| console.error("Image URL insert failed", err); | ||
| toast.error( | ||
| err instanceof Error | ||
| ? `Couldn't load image: ${err.message}` | ||
| : "Couldn't load image", | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
Avoid persisting third-party image hotlinks.
insertImageFromUrl() stores the remote URL as-is. That means every future viewer of the doc will fetch arbitrary third-party resources from their browser, which is a privacy/tracking leak and makes rendering depend on an external host staying reachable. Please import URL-based images into first-party storage (or proxy them through a trusted backend path) before persisting style.image.src.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/frontend/src/app/docs/image-insert.ts` around lines 78 - 111,
insertImageFromUrl currently persists third‑party hotlinked URLs; change it to
fetch/proxy and store the image on our trusted storage before calling
editor.insertImage so viewers won't hotlink external hosts. Keep the existing
validation and loadImageDimensions call to get width/height, then call a new or
existing helper (e.g. uploadImageFromUrl or proxyFetchImage) to download and
upload the remote image to first‑party storage or return a proxied URL, and pass
that returned hosted URL (not the original trimmed URL) into
EditorAPI.insertImage while preserving originalWidth/originalHeight metadata and
error handling.
dragover handler checked dataTransfer.files which browsers leave empty during dragover for security. Without preventDefault() the browser falls through to its default: navigate to the dropped image URL. Switch to dataTransfer.items (which exposes MIME types during drag) for the dragover check, and dataTransfer.files for the actual drop where the File object is needed. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Sync design doc insertImage signature with actual API (src, width, height, opts?) and note that caret-insert (not selection-replace) is the current behavior - Mark Floating Context Bar and Image Options Side Panel as "Planned — Milestone 5/6" in design doc - Update task doc: remove stale crossOrigin="anonymous" reference, note dataTransfer.items fix for dragover - insertImageFromUrl returns boolean so toolbar keeps URL form open on failure instead of discarding user input - Guard clampImageToWidth against non-positive maxWidth - Clear selectedImage/imageResizeDrag/imageFileDropCallback in dispose() to prevent stale closure references Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/docs/src/view/editor.ts`:
- Around line 1414-1435: The drop/paste flow (handleImageDrop) currently moves
the global cursor with cursor.moveTo then calls imageFileDropCallback(file),
which lets async uploads later insert at the current cursor instead of the
original drop position; capture the resolved DocPosition (use the hit from
paginatedPixelToPosition or the current cursor position if no hit) immediately
and pass that position (or an insertion closure bound to it) into
imageFileDropCallback instead of only the File so downstream code (e.g.,
insertImage) can insert at the original drop/paste location; apply the same
pattern to the other paste handler around the 2049-2058 area.
- Around line 1263-1299: handleImageResizeMouseUp and the related image-editing
paths (imageResizeDrag, handleImageResizeMouseMove, selection logic at
findImageAtOffset, and the mutation call doc.applyInlineStyle) are allowed to
run even when readOnly is true; guard these paths and the public mutating APIs
(insertImage, updateSelectedImage) with a check for !readOnly. Specifically,
before wiring event handlers or starting a drag/selection (where imageResizeDrag
is set and handleImageResizeMouseMove/handleImageResizeMouseUp are attached) and
before committing mutations (the docStore.snapshot()/doc.applyInlineStyle
sequence in handleImageResizeMouseUp), return early if readOnly is true;
likewise add the same !readOnly guard to insertImage and updateSelectedImage so
they no-op or throw when readOnly is set. Ensure rendering/cleanup still happens
(remove listeners, reset cursor) but prevent any state changes or
document.applyInlineStyle calls when readOnly is true.
- Around line 1352-1360: When an image is selected via findImageAtPoint (the
code path that sets selectedImage, calls cursor.moveTo and render), clear any
active text selection by calling selection.setRange(null) so printable keys
don't first clear selectedImage and then apply normal replace-selection
behavior; add the same selection.setRange(null) in the other image-selection
branch (the block around the second occurrence of selectedImage/cursor.moveTo at
the 2004-2010 area) to ensure consistent image-selection mode.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 60dddcc2-af0c-40b0-8c46-f3dcfa58eaa7
📒 Files selected for processing (1)
packages/docs/src/view/editor.ts
A) Gate image editing paths behind !readOnly so a read-only editor cannot select, resize, drag-drop, or mutate images. TextEditor is already null when readOnly; this guards the container-level listeners and the insertImage/updateSelectedImage API methods. B) Clear selection.range when entering image-selection mode so a stale text selection doesn't cause unintended text deletion when the user types while an image is selected. C) Capture the resolved insertion position at drop/paste time and thread it through the callback → insertImageFromFile → insertImage so async uploads land at the original drop location even if the cursor moves during the upload. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Summary
Milestones delivered
ImageDataextended with rotation/crop/original* fields,EditorAPIimage methodscomputeResizeDelta, preview rect, cursor hints, drag-lift shadowimage-insert.tshelpers, DnD/paste wiring,clampImageToWidth546 tests pass (
pnpm verify:fastEXIT=0).Bug fixes included
collectImageRectsdouble-addedmargins.left(pl.x already includes it)handleMouseMovealways reset cursor totext, overriding handle hover hintsloadImageDimensionsforced CORScrossOrigin="anonymous"which broke non-CORS image hostsInsertImageDropdowndidn't reseturlModeon programmatic closeOut of scope (Phase 2+)
Test plan
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation
Tests