Skip to content

Commit 7bdb1b6

Browse files
alicodingclaude
andauthored
fix: an empty note is draggable, and the image tool takes a pasted file path (#523)
Two owner-reported Atlas bugs, both live-reproduced and now e2e-pinned: - An empty sticky note rendered the editing-style invitation with nodrag/nopan on its whole surface, so it could never be moved or click-selected. The invitation now stays a normal draggable board node: the editor subtree is pointer-inert at rest (Crepe's placeholder decoration needs the editable mount, so read-only wasn't an option), a single click still starts typing, shift/cmd clicks match the at-rest gesture table. - The image popover's paste zone answered only bitmap clipboard data; a pasted image-file path (or image URL) as text did nothing. It now routes image-shaped text through the same server-side recognizer the board's own paste door already runs (PasteToBoard -> mirror-copy, existence check, URL fetch), with quote/file:// normalization in the new imagePathFromClipboardText gate. The board's window-level paste door now respects defaultPrevented so the two doors can never land the same paste twice (live-caught: one paste, two objects). Non-image text pasted into the zone gets the inline extension message instead of silence, and the hint copy names the path option. Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent c7df6ab commit 7bdb1b6

14 files changed

Lines changed: 238 additions & 32 deletions

frontend/e2e/atlas-authoring.spec.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
} from './fixtures/server'
1313
import { contextMenu, rightClickEmptyArea } from './fixtures/contextMenu'
1414
import { ATLAS_KIND_CONTACT, ATLAS_KIND_TOPIC, selectKind } from './fixtures/kindPicker'
15-
import { clickCorner, groupCard, noteCard, zoomAllTheWayOut } from './fixtures/atlasBoard'
15+
import { clickCorner, dragBetween, groupCard, noteCard, zoomAllTheWayOut } from './fixtures/atlasBoard'
1616

1717
// Atlas creation core (goal 0081 slice A1): the tray, its placement
1818
// popover, right-click create, sticky notes, and the note promotion
@@ -251,6 +251,17 @@ test('an empty note places, shows its own placeholder inline, and takes text lat
251251
// Milkdown's own placeholder feature sets, not rendered text.
252252
await expect(sticky.locator('.crepe-placeholder')).toHaveAttribute('data-placeholder', 'Type a note…')
253253

254+
// Regression: the invitation's whole surface carried `nodrag`, so
255+
// an empty note could never be moved at all. A body drag moves it
256+
// like any other node -- and a moved gesture never opens an edit
257+
// session (the drag machinery suppresses its trailing click).
258+
const beforeBox = await sticky.boundingBox()
259+
if (!beforeBox) throw new Error('empty sticky has no bounding box')
260+
const center = { x: beforeBox.x + beforeBox.width / 2, y: beforeBox.y + beforeBox.height / 2 }
261+
await dragBetween(page, center, { x: center.x + 120, y: center.y + 80 })
262+
await expect.poll(async () => (await sticky.boundingBox())?.x ?? 0).toBeGreaterThan(beforeBox.x + 60)
263+
await expect(stickyEditor(page)).toHaveCount(0)
264+
254265
// A single press into the empty note's own field starts typing
255266
// directly -- no prior selection needed (unlike a non-empty note's
256267
// select-then-click), since the placeholder IS the invitation.

frontend/e2e/atlas-image-tool.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import fs from 'node:fs'
2+
import os from 'node:os'
3+
import path from 'node:path'
14
import { test, expect } from './fixtures/server'
25
import { dragResizeHandle, nonSeededBoardObjects, openCard } from './fixtures/atlasBoard'
36
import { deleteViaPageMenu } from './fixtures/atlasPage'
@@ -124,6 +127,50 @@ test('pasting a clipboard image lands a board object -- selectable, draggable, d
124127
await expect(object).toHaveCount(0)
125128
})
126129

130+
// Regression: the paste zone answered only bitmap clipboard data -- a
131+
// pasted image-file PATH (plain text) was silently ignored, and the
132+
// board's window-level paste door acting on the same event could land
133+
// a second copy. The zone now routes image-shaped text through the
134+
// same server-side recognizer the board door uses (mirror-copy), and
135+
// marks the event handled so exactly ONE object lands.
136+
test('pasting an image file path as text lands a board object rendering that file', async ({ page }) => {
137+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mill-e2e-atlas-image-paste-path-'))
138+
const pngPath = path.join(dir, 'ZzE2ePastedPath.png')
139+
fs.writeFileSync(pngPath, Buffer.from(ONE_PIXEL_PNG_BASE64, 'base64'))
140+
try {
141+
await page.goto('/')
142+
await page.getByRole('link', { name: 'Atlas' }).click()
143+
await expect(page.getByTestId('atlas-board')).toBeVisible()
144+
145+
await openImagePopover(page)
146+
// Same dispatched-ClipboardEvent escape hatch as the bitmap-paste
147+
// test above: no user primitive can place arbitrary text on the
148+
// real OS clipboard portably in this harness, and the dispatched
149+
// event carries the exact DataTransfer shape a real ⌘V delivers.
150+
await page.getByTestId('atlas-image-paste-zone').evaluate((el, p) => {
151+
const dt = new DataTransfer()
152+
dt.setData('text/plain', p)
153+
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }))
154+
}, pngPath)
155+
156+
const object = imageObjects(page)
157+
await expect(object).toHaveCount(1)
158+
// The object renders the file found at the pasted path -- a real
159+
// <img> confirms the bytes loaded, not just a titled box.
160+
await expect(object.locator('img')).toBeVisible()
161+
// The popover closes itself once the paste resolves.
162+
await expect(page.getByTestId('atlas-image-input')).not.toBeVisible()
163+
// The rule, absolute: never a card.
164+
await expect(page.getByTestId('atlas-note-card').filter({ hasText: 'ZzE2ePastedPath' })).toHaveCount(0)
165+
166+
await object.click()
167+
await page.keyboard.press('Delete')
168+
await expect(object).toHaveCount(0)
169+
} finally {
170+
fs.rmSync(dir, { recursive: true, force: true })
171+
}
172+
})
173+
127174
// Note (goal 0206): the prior "a non-image path shows an inline error"
128175
// case tested typing an arbitrary bad path into a free-text field --
129176
// that field no longer exists (replaced by the native picker, which

frontend/src/atlas/AtlasBoard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,7 @@ function AtlasBoardInner({ boardFilter, onBoardFilterChange, filterMatchCount, f
471471
{fileDrop.dropDuplicateNotice && <div className={styles.dropNotice} data-testid="atlas-file-drop-duplicate-notice">{fileDrop.dropDuplicateNotice}</div>}
472472
{!readOnly && (haveSelection
473473
? <AtlasSelectionTray ref={trayRef} selectedCardCount={selection.selectedCards.length} selectedNoteCount={selection.selectedNotes.length} selectedObjectCount={selection.selectedObjects.length} onGroup={onTrayGroup} onDelete={onTrayDelete} />
474-
: <AtlasCreationTray armedTool={armedTool.armedToolId} locked={creation.locked} onToggle={creation.toggleArm} tablePickerOpen={tablePicker.open} onTableToggle={tablePicker.setOpen} onClosePickerVisibility={tablePicker.closePickerVisibility} onPickTableSize={(cols, rows) => tablePicker.setPendingSize({ cols, rows })} onTableFromList={onOpenTableFromList} imagePopoverOpen={imagePopover.open} onImageToggle={imagePopover.setOpen} onImageSubmitPath={imageCreate.createFromPath} onImageSubmitFile={imageCreate.createFromFile} />)}
474+
: <AtlasCreationTray armedTool={armedTool.armedToolId} locked={creation.locked} onToggle={creation.toggleArm} tablePickerOpen={tablePicker.open} onTableToggle={tablePicker.setOpen} onClosePickerVisibility={tablePicker.closePickerVisibility} onPickTableSize={(cols, rows) => tablePicker.setPendingSize({ cols, rows })} onTableFromList={onOpenTableFromList} imagePopoverOpen={imagePopover.open} onImageToggle={imagePopover.setOpen} onImageSubmitPath={imageCreate.createFromPath} onImageSubmitFile={imageCreate.createFromFile} onImageSubmitText={imageCreate.createFromPastedText} />)}
475475
{creation.popover && (
476476
<AtlasPlacementPopover
477477
mode={creation.popover.mode}

frontend/src/atlas/AtlasCreationTray.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ const PRIMARY_GROUP_ORDER = ['knowledge', 'file'] as const
7171
// near the board's bottom-left. The full name is still discoverable
7272
// via `title` (hover) and `aria-label` (screen readers) on every
7373
// button, including the Annotate group's own trigger.
74-
export function AtlasCreationTray({ armedTool, locked, onToggle, tablePickerOpen, onTableToggle, onClosePickerVisibility, onPickTableSize, onTableFromList, imagePopoverOpen, onImageToggle, onImageSubmitPath, onImageSubmitFile }: {
74+
export function AtlasCreationTray({ armedTool, locked, onToggle, tablePickerOpen, onTableToggle, onClosePickerVisibility, onPickTableSize, onTableFromList, imagePopoverOpen, onImageToggle, onImageSubmitPath, onImageSubmitFile, onImageSubmitText }: {
7575
// The ONE shared armed-tool field (useAtlasArmedTool.ts, goal 0238)
7676
// -- widened past AtlasArmableTool so Table/Image share the exact
7777
// same value every OTHER tool's own `data-armed`/`aria-pressed`
@@ -102,6 +102,7 @@ export function AtlasCreationTray({ armedTool, locked, onToggle, tablePickerOpen
102102
onImageToggle: (open: boolean) => void
103103
onImageSubmitPath: (path: string) => Promise<void>
104104
onImageSubmitFile: (file: File) => Promise<void>
105+
onImageSubmitText: (text: string) => Promise<void>
105106
}) {
106107
const { t } = useTranslation('atlas')
107108
// Settings > Extensions disable semantics, item 1: a disabled tool's
@@ -284,7 +285,7 @@ export function AtlasCreationTray({ armedTool, locked, onToggle, tablePickerOpen
284285
renderAnchor={null}
285286
side="outside-top"
286287
>
287-
<AtlasImageInput onSubmitPath={onImageSubmitPath} onSubmitFile={onImageSubmitFile} onDone={() => onImageToggle(false)} />
288+
<AtlasImageInput onSubmitPath={onImageSubmitPath} onSubmitFile={onImageSubmitFile} onSubmitText={onImageSubmitText} onDone={() => onImageToggle(false)} />
288289
</AnchoredOverlay>
289290
</Fragment>
290291
)

frontend/src/atlas/AtlasImageInput.tsx

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
33
import { Button, Text } from '@primer/react'
44
import { AtlasService } from '../shared/bindings'
55
import { readClipboardImageFile } from '../shared/clipboardRead'
6+
import { imagePathFromClipboardText } from './atlasCreateHelpers'
67
import { IMAGE_EXTENSIONS } from './atlasUnitMirror'
78
import { extensionOf } from './unitRegistry'
89
import styles from './AtlasImageInput.module.css'
@@ -14,9 +15,10 @@ import styles from './AtlasImageInput.module.css'
1415
// user is asked to type (ux-writing.md). Rendering the created object
1516
// is the placement door's job (useAtlasImageCreate.ts); this component
1617
// only resolves WHICH path or file to hand off.
17-
export function AtlasImageInput({ onSubmitPath, onSubmitFile, onDone }: {
18+
export function AtlasImageInput({ onSubmitPath, onSubmitFile, onSubmitText, onDone }: {
1819
onSubmitPath: (path: string) => Promise<void>
1920
onSubmitFile: (file: File) => Promise<void>
21+
onSubmitText: (text: string) => Promise<void>
2022
onDone: () => void
2123
}) {
2224
const { t } = useTranslation('atlas')
@@ -74,10 +76,41 @@ export function AtlasImageInput({ onSubmitPath, onSubmitFile, onDone }: {
7476
tabIndex={0}
7577
autoFocus
7678
onPaste={(e) => {
79+
// preventDefault on every handled shape below also tells the
80+
// board's own window-level paste door (useAtlasPaste.ts) to
81+
// stand down -- without it, both doors land the same paste
82+
// and the object appears twice.
7783
const file = readClipboardImageFile(e.clipboardData)
78-
if (!file) return
79-
e.preventDefault()
80-
submitFile(file)
84+
if (file) {
85+
e.preventDefault()
86+
submitFile(file)
87+
return
88+
}
89+
// No bitmap on the clipboard: a pasted image-file PATH or
90+
// image URL (text) lands through the same server-side
91+
// recognizer the board's own paste door uses. Text that
92+
// isn't image-shaped gets the same inline answer the
93+
// picker's own extension re-check gives, never silence.
94+
const path = imagePathFromClipboardText(
95+
e.clipboardData.getData('text/uri-list'),
96+
e.clipboardData.getData('text/plain'),
97+
)
98+
if (path) {
99+
e.preventDefault()
100+
setBusy(true)
101+
setError(null)
102+
onSubmitText(path)
103+
.then(onDone)
104+
.catch(() => {
105+
setBusy(false)
106+
setError(t('imageInput.addFailed'))
107+
})
108+
return
109+
}
110+
if (e.clipboardData.getData('text/plain').trim() !== '') {
111+
e.preventDefault()
112+
setError(t('imageInput.invalidExtension'))
113+
}
81114
}}
82115
>
83116
<Text size="small" className={styles.hint} data-testid="atlas-image-paste-hint">

frontend/src/atlas/AtlasStickyNode.module.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,21 @@
3333
.sticky.editing {
3434
cursor: text;
3535
}
36+
/* The empty-note invitation at rest: the SAME editable mount as an
37+
edit session (Crepe's placeholder plugin bails on `crepe.readonly`,
38+
so a read-only mount would lose the "Type a note…" decoration), but
39+
the node itself must stay a normal draggable/selectable board object
40+
-- so the editor subtree is pointer-inert until a click promotes it
41+
to a real session, and every press belongs to the canvas library's
42+
own drag/select. The resize-control exclusion mirrors the width rule
43+
below: NodeResizer's handles are direct children of this wrapper. */
44+
.sticky.editing.invitation {
45+
cursor: pointer;
46+
}
47+
.sticky.editing.invitation > *:not(:global(.react-flow__resize-control)) {
48+
pointer-events: none;
49+
}
50+
3651
/* The editor's own wrapper fills the note's width and carries the
3752
growth cap: content-driven height stops here and wheel-scrolls past
3853
it (goal 0199's bounded-editor contract, checked by

frontend/src/atlas/AtlasStickyNode.tsx

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -228,30 +228,37 @@ export const AtlasStickyNode = memo(function AtlasStickyNode({ data, selected }:
228228
return (
229229
<div
230230
ref={wrapRef}
231-
className={`${styles.sticky} ${styles.editing} nodrag nopan nowheel`}
231+
// nodrag/nopan/nowheel only while a REAL edit session runs: the
232+
// empty-note invitation (editing false) must stay an ordinary
233+
// draggable/selectable board node -- its editor subtree is
234+
// pointer-inert instead (styles.invitation), so the canvas
235+
// library owns every press until a click promotes to editing.
236+
className={`${styles.sticky} ${styles.editing} ${editing ? 'nodrag nopan nowheel' : styles.invitation}`}
232237
style={boxStyle}
233238
data-testid="atlas-sticky-note"
234239
data-editing={editing ? 'true' : 'false'}
235-
// A press into an empty note (not yet a real editing session,
236-
// just showing its own placeholder) promotes to a real one --
237-
// the same promotion MarkdownNoteField's onFocus does. Idle
238-
// while already editing (editing is already true).
240+
// Keyboard focus (Tab) landing on the invitation's field
241+
// promotes to a real editing session -- the same promotion
242+
// MarkdownNoteField's onFocus does; pointer presses never
243+
// focus the field (it is pointer-inert at rest). Idle while
244+
// already editing (editing is already true).
239245
onFocus={() => {
240246
if (!editing) onEnterEdit()
241247
}}
242-
// A short/empty note's own contenteditable is only as tall as
243-
// its one line of placeholder text (a percentage-height chain
244-
// to fill the box was tried and measured unreliable --
245-
// MilkdownEditor.module.css's own header carries that finding)
246-
// -- a press anywhere in the REMAINING box below it must still
247-
// focus the field, the same way clicking blank space in a
248-
// native textarea does. Idle once a real click already landed
249-
// on the editable itself (this would just re-focus the same
250-
// element).
248+
// A single click anywhere in the invitation starts typing --
249+
// no prior selection needed, the placeholder IS the invitation
250+
// (unlike a non-empty note's select-then-click). Modifier
251+
// gestures match the at-rest branch: shift leaves the click to
252+
// multi-select, ⌘ opens the big surface. A real drag never
253+
// reaches here -- the drag machinery suppresses the click that
254+
// follows a moved gesture.
251255
onClick={(e) => {
252-
if (editing) return
253-
const target = e.currentTarget.querySelector<HTMLElement>('[contenteditable="true"], textarea')
254-
target?.focus()
256+
if (editing || e.shiftKey) return
257+
if (e.metaKey || e.ctrlKey) {
258+
onOpenBig()
259+
return
260+
}
261+
onEnterEdit()
255262
}}
256263
onKeyDown={(e) => {
257264
if (e.key === 'Escape') {

frontend/src/atlas/atlasCreateHelpers.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest'
2-
import { normalizeLocalPathInput, resolveDefaultKindID, resolveNoteCommitText, titleFromFilename, titleFromNoteText } from './atlasCreateHelpers'
2+
import { imagePathFromClipboardText, normalizeLocalPathInput, resolveDefaultKindID, resolveNoteCommitText, titleFromFilename, titleFromNoteText } from './atlasCreateHelpers'
33
import type { Kind } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models'
44

55
function kind(id: string): Kind {
@@ -122,3 +122,48 @@ describe('resolveNoteCommitText', () => {
122122
expect(resolveNoteCommitText('')).toBeNull()
123123
})
124124
})
125+
126+
describe('imagePathFromClipboardText', () => {
127+
// Regression: the image paste zone answered only bitmap clipboard
128+
// data -- a pasted image-file path (text) was silently ignored.
129+
it('accepts a plain absolute image path', () => {
130+
expect(imagePathFromClipboardText('/Users/me/photo.png')).toBe('/Users/me/photo.png')
131+
})
132+
133+
it('trims surrounding whitespace and a trailing newline', () => {
134+
expect(imagePathFromClipboardText(' /Users/me/photo.jpg \n')).toBe('/Users/me/photo.jpg')
135+
})
136+
137+
it('strips matched wrapping quotes from a terminal-copied path', () => {
138+
expect(imagePathFromClipboardText('"/Users/me/My Photo.png"')).toBe('/Users/me/My Photo.png')
139+
expect(imagePathFromClipboardText("'/Users/me/photo.webp'")).toBe('/Users/me/photo.webp')
140+
})
141+
142+
it('resolves a file:// URL through the same normalization the picker uses', () => {
143+
expect(imagePathFromClipboardText('file:///Users/me/My%20Photo.png')).toBe('/Users/me/My Photo.png')
144+
})
145+
146+
it('takes only the first line of a uri-list flavor, in preference order', () => {
147+
expect(imagePathFromClipboardText('file:///a/first.png\nfile:///b/second.png', '/c/plain.png')).toBe('/a/first.png')
148+
})
149+
150+
it('falls through an unusable first flavor to a later one', () => {
151+
expect(imagePathFromClipboardText('', '/Users/me/photo.gif')).toBe('/Users/me/photo.gif')
152+
})
153+
154+
it('passes an image URL through untouched for the server-side recognizer', () => {
155+
expect(imagePathFromClipboardText('https://example.com/pics/logo.png')).toBe('https://example.com/pics/logo.png')
156+
})
157+
158+
it('rejects a path with a non-image extension', () => {
159+
expect(imagePathFromClipboardText('/Users/me/notes.txt')).toBeNull()
160+
})
161+
162+
it('rejects prose that is not a path at all', () => {
163+
expect(imagePathFromClipboardText('hello world')).toBeNull()
164+
})
165+
166+
it('rejects empty input', () => {
167+
expect(imagePathFromClipboardText('', ' ')).toBeNull()
168+
})
169+
})

frontend/src/atlas/atlasCreateHelpers.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { Kind } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models'
2+
import { extensionOf } from './unitRegistry'
3+
import { IMAGE_EXTENSIONS } from './atlasUnitMirror'
24

35
// The placement popover's own two pure decisions (goal 0081 slice A1),
46
// split out so both are Vitest-unit-testable without a React render --
@@ -67,6 +69,31 @@ export function normalizeLocalPathInput(input: string): string {
6769
}
6870
}
6971

72+
// imagePathFromClipboardText resolves a pasted TEXT clipboard into an
73+
// image-file path or image URL, or null when no candidate is one --
74+
// the image paste zone's own SHAPE gate after readClipboardImageFile
75+
// finds no bitmap (an http(s) URL whose path ends in an image
76+
// extension passes through untouched; the server-side recognizer owns
77+
// fetching it).
78+
// Callers pass the paste's text flavors in preference order (a
79+
// `text/uri-list` before `text/plain`); only each flavor's FIRST line
80+
// is considered (uri-list is defined as one URI per line, and a
81+
// multi-line plain-text paste isn't a path). Wrapping quotes are
82+
// stripped (a path copied out of a terminal often carries them), then
83+
// the same normalize + extension gate the picker's own re-check uses
84+
// decides. Pure -- existence is the backend's own concern, exactly as
85+
// it is for a picked path.
86+
export function imagePathFromClipboardText(...candidates: string[]): string | null {
87+
for (const candidate of candidates) {
88+
const firstLine = (candidate.split('\n')[0] ?? '').trim()
89+
const unquoted = /^(['"]).*\1$/.test(firstLine) ? firstLine.slice(1, -1) : firstLine
90+
if (!unquoted) continue
91+
const path = normalizeLocalPathInput(unquoted)
92+
if (IMAGE_EXTENSIONS.has(extensionOf(path))) return path
93+
}
94+
return null
95+
}
96+
7097
// resolveNoteCommitText decides whether a re-edited note's text should
7198
// persist, and exactly what to persist (goal 0226's round-trip
7299
// contract): null skips the write entirely (an existing note's own

0 commit comments

Comments
 (0)