Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
440 changes: 440 additions & 0 deletions claude-notes/plans/2026-08-27-paste-image-clipboard.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions hub-client/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ WASM rebuild is needed for a changelog-only edit.

### 2026-08-27

- [`e0e2d2bbc`](https://github.com/quarto-dev/q2/commits/e0e2d2bbc): Paste images directly into the source editor — Cmd/Ctrl-V with an image on the clipboard (a screenshot, a copied image, or a copied image file) uploads it next to the current document under an automatic content-based name and inserts the image reference at the cursor, with no dialog; selected text becomes the alt text. Text pastes and text-plus-image pastes (like spreadsheet cells) behave exactly as before, and SVG files still go through the upload dialog.
- [`e279bc9c9`](https://github.com/quarto-dev/q2/commits/e279bc9c9): `local-prod:nginx` now accepts `--port` like plain `local-prod`, and starts correctly when `OIDC_CLIENT_ID` in your shell enables hub auth — the readiness check previously failed on the hub's 401 even though the hub was up.

### 2026-08-26
Expand Down
84 changes: 76 additions & 8 deletions hub-client/src/components/Editor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
// Side effects only: bundles Monaco + workers so no runtime CDN fetch occurs.
import '../monacoSetup';
import MonacoEditor from '@monaco-editor/react';
Expand All @@ -21,8 +21,13 @@ import { hasExecutableCells } from '../services/executableCells';
import type { Diagnostic, RenderComment } from '@quarto/preview-renderer/types/diagnostic';
import { useIntelligenceProviders } from '../hooks/useIntelligenceProviders';
import { registerQmdLanguage } from './quartoTheme';
import { processFileForUpload } from '../services/resourceService';
import { buildDropMarkdown, resolveDefaultDestination } from './fileUpload';
import { processFileForUpload, FILE_SIZE_LIMITS } from '../services/resourceService';
import {
buildDropMarkdown,
resolveDefaultDestination,
classifyPastePayload,
createPasteImageHandler,
} from './fileUpload';
import { useProjectSearch } from '../services/search';
import { usePresence } from '../hooks/usePresence';
import { usePreference } from '../hooks/usePreference';
Expand Down Expand Up @@ -689,12 +694,17 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC
}
});

// Attach drag-drop handlers to editor container
// Attach drag-drop and paste handlers to editor container
const domNode = editor.getDomNode();
if (domNode) {
domNode.addEventListener('dragover', handleEditorDragOver);
domNode.addEventListener('dragleave', handleEditorDragLeave);
domNode.addEventListener('drop', handleEditorDrop);
// Capture phase: the paste event targets Monaco's hidden textarea,
// and Monaco's own listener (which preventDefaults and re-implements
// text paste) sits on that textarea — capture on the container runs
// first, so image payloads can be intercepted (bd-706b0ixu).
domNode.addEventListener('paste', handleEditorPaste, true);
}

// Signal that editor is ready for scroll sync
Expand Down Expand Up @@ -894,19 +904,77 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC
setShowNewAssetDialog(true);
}, [currentFile]);

// Cleanup editor drag-drop listeners on unmount. Note: intelligence-provider
// disposal lives in useIntelligenceProviders (mount-only) — it must NOT be
// coupled to `handleEditorDrop`, whose identity changes with `currentFile`.
// Clipboard image paste (bd-706b0ixu, see
// claude-notes/plans/2026-08-27-paste-image-clipboard.md): silent
// ingest — content-hash-named file next to the current document, then
// a markdown reference at the cursor. Every dep reads through a ref or
// a stable callback, so both callbacks below are identity-stable and
// the DOM listener can be attached once at editor mount.
const pasteImageIngest = useMemo(
() =>
createPasteImageHandler({
getCurrentFilePath,
getEditor: () => {
const editor = editorRef.current;
if (!editor) return null;
return {
getSelection: () => editor.getSelection(),
getTextInRange: (range) =>
editor.getModel()?.getValueInRange(range) ?? '',
replaceRange: (range, text) => {
// Monaco's onChange fires synchronously from executeEdits,
// updating both React state and CRDT via the splice path.
editor.executeEdits('image-paste', [
{ range, text, forceMoveMarkers: true },
]);
},
};
},
processFile: processFileForUpload,
createBinaryFile,
maxFileSize: FILE_SIZE_LIMITS.MAX_FILE_SIZE,
onError: (message) => console.error(message),
}),
[getCurrentFilePath]
);

const handleEditorPaste = useCallback(
(e: ClipboardEvent) => {
const clipboard = e.clipboardData;
if (!clipboard) return;
const files = Array.from(clipboard.files);
if (
classifyPastePayload({
files: files.map((f) => ({ name: f.name, type: f.type, size: f.size })),
text: clipboard.getData('text/plain'),
}) !== 'take-over'
) {
return; // text and mixed payloads: Monaco's paste handling runs
}
// Taking over: stop Monaco's own handler (it would insert the
// filename rider as stray text) before the async ingest starts.
e.preventDefault();
e.stopPropagation();
void pasteImageIngest(files);
},
[pasteImageIngest]
);

// Cleanup editor drag-drop/paste listeners on unmount. Note:
// intelligence-provider disposal lives in useIntelligenceProviders
// (mount-only) — it must NOT be coupled to `handleEditorDrop`, whose
// identity changes with `currentFile`.
useEffect(() => {
return () => {
const domNode = editorRef.current?.getDomNode();
if (domNode) {
domNode.removeEventListener('dragover', handleEditorDragOver);
domNode.removeEventListener('dragleave', handleEditorDragLeave);
domNode.removeEventListener('drop', handleEditorDrop);
domNode.removeEventListener('paste', handleEditorPaste, true);
}
};
}, [handleEditorDragOver, handleEditorDragLeave, handleEditorDrop]);
}, [handleEditorDragOver, handleEditorDragLeave, handleEditorDrop, handleEditorPaste]);

// Handle creating a new text file
const handleCreateTextFile = useCallback(async (path: string, initialContent: string) => {
Expand Down
12 changes: 12 additions & 0 deletions hub-client/src/components/fileUpload/dropMarkdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ describe('buildDropMarkdown', () => {
it('falls back to the target path verbatim without a current file', () => {
expect(buildDropMarkdown('image', null, 'photo.png')).toBe('![](photo.png)');
});

it('includes alt text when provided', () => {
expect(
buildDropMarkdown('image', 'posts/hello.qmd', 'posts/photo.png', 'a caption')
).toBe('![a caption](photo.png)');
});

it('treats an empty alt text like the default', () => {
expect(
buildDropMarkdown('image', 'posts/hello.qmd', 'posts/photo.png', '')
).toBe('![](photo.png)');
});
});

describe('link markdown', () => {
Expand Down
5 changes: 3 additions & 2 deletions hub-client/src/components/fileUpload/dropMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@ export type DropMarkdownKind = 'image' | 'link';
export function buildDropMarkdown(
kind: DropMarkdownKind,
currentFilePath: string | null,
targetPath: string
targetPath: string,
altText: string = ''
): string {
const href = currentFilePath
? relativePathBetween(currentFilePath, targetPath)
: targetPath;

if (kind === 'image') {
return `![](${href})`;
return `![${altText}](${href})`;
}
const fileName = targetPath.split('/').pop() || targetPath;
return `[${fileName}](${href})`;
Expand Down
16 changes: 16 additions & 0 deletions hub-client/src/components/fileUpload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,19 @@ export {
} from './resolveDefaultDestination';
export { processAssetFiles, type AssetFilePreview } from './processAssetFiles';
export { buildDropMarkdown, type DropMarkdownKind } from './dropMarkdown';
export {
classifyPastePayload,
pastedImageFilename,
sanitizeAltText,
ACCEPTED_PASTE_IMAGE_TYPES,
type PastePayload,
type PastePayloadFile,
type PasteClassification,
} from './pasteImages';
export {
createPasteImageHandler,
type CreatePasteImageHandlerDeps,
type PasteImageEditor,
type PasteImageHandler,
type PasteRange,
} from './pasteImageHandler';
Loading
Loading