diff --git a/docs/DEMO_LIBRARY.md b/docs/DEMO_LIBRARY.md index a30e8497..b5c8f5f2 100644 --- a/docs/DEMO_LIBRARY.md +++ b/docs/DEMO_LIBRARY.md @@ -64,7 +64,8 @@ shipped types, including Scratchpad, PRD, Test Plan, and Handoff. | discussion-margins | Crowded prose discussions, multiple participants, resolved visibility, and slide navigation/scrolling; avatars identify starters, not unread activity | | reading-specimen | A realistic design doc for judging reading in both modes: prose, headings, lists, tasks, quote, table, footnote, image, SQL/Ruby/diff/YAML code, a long scrolling line, flowchart and sequence diagrams, plus open, agent-answered, resolved, diagram-node, table-cell, and general discussions | | document-title | One editable title; autosave keeps the URL stable until Done; title boundaries and empty-title behavior | -| image-resizing | Resize and restore image widths, wrap prose from the top, Shift+Enter beside, Enter below | +| image-resizing | Drop or paste files to upload and embed; duplicate drops reuse files; resize images, wrap prose, Shift+Enter beside, Enter below | +| file-uploads | Drop the same image or file again: one attachment and stored copy, multiple embeds; renamed copies, Dual, and Undo | | callout-gallery | `> [!SUMMARY]`, `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, and `[!CAUTION]` callouts, a custom label, nested list and code, plain and escaped quotes, and both deck themes; comment inside a callout, or edit one in Editor and check Raw | | math-gallery | KaTeX math: inline, display, and fenced formulas, math in lists and tables, a six-row slide equation, dollar amounts that stay text, and invalid math shown as source; comment on the text around a formula | diff --git a/docs/human-document-editor.md b/docs/human-document-editor.md index 202f4862..afe9cf9f 100644 --- a/docs/human-document-editor.md +++ b/docs/human-document-editor.md @@ -104,6 +104,12 @@ Images have a bottom-right resize handle that appears on hover or keyboard focus Images pasted from other apps start at their natural size, capped by the document width. A copied thumbnail's display width is ignored. Copying an image within Coplan's rich editor preserves its intentional resize. Existing saved widths and widths written in Raw Markdown stay unchanged. +Drop image or file uploads into the rich Editor or the rich pane in Dual. Images appear inline; other supported files become download links. Clipboard image files upload too. Upload markers follow their insertion points through continued typing and live updates, without entering saved Markdown. Uploads use the plan's existing type and 25 MB size limits. A new document is created before its first upload. Done and navigation wait for pending uploads before saving. Partial failures preserve accepted files and show per-file errors. Drop failed files again to retry. Undo removes an embed while keeping the file in Attachments; deleting its drop point during upload leaves the file available there too. + +Identical uploaded contents reuse an existing attachment within the same document, preserving its original filename and uploader. Every drop still inserts an embed at that location. The notice distinguishes uploads from reuse. Different contents, even with the same filename, stay separate. Concurrent identical uploads converge on one attachment and stored blob; reuse does not log another attachment-added event. Existing duplicate attachments are left intact so their saved links continue working. + +Upload errors stay visible until dismissed, including after another file succeeds. Done and navigation keep the editor open while these errors are unacknowledged. Dismiss the notice before leaving; your writing and accepted files remain saved. + Text after a paragraph's leading image wraps beside it from the top when there is room, in both editing and reading views. Enter starts a paragraph below the image; Shift+Enter keeps a line break beside it. In Raw Markdown, a blank line starts the paragraph below. Images inside a sentence remain inline. **+ → Embed** adds a self-closing iframe block with URL, title, width, diff --git a/engine/app/assets/stylesheets/coplan/document-editor.css b/engine/app/assets/stylesheets/coplan/document-editor.css index 9ee5cadd..95f4124b 100644 --- a/engine/app/assets/stylesheets/coplan/document-editor.css +++ b/engine/app/assets/stylesheets/coplan/document-editor.css @@ -184,3 +184,54 @@ html[data-theme="light"] .document-editor select, html[data-theme="light"] .docu .document-editor[data-mode="dual"] .document-editor__raw { padding-right: 0; padding-bottom: 24px; border-right: 0; border-bottom: 1px solid var(--color-border); } .document-editor[data-mode="dual"] .ProseMirror { min-height: 200px; } } +.document-editor .ProseMirror.is-file-drop-target { + outline: 2px solid var(--color-primary); + outline-offset: 6px; + background: var(--color-interaction-hover-bg); +} + +.document-editor__upload-placeholder { + display: inline-flex; + align-items: center; + gap: 0.5rem; + max-width: 100%; + padding: 0.35rem 0.65rem; + border: 1px solid var(--color-primary); + border-radius: 0.5rem; + color: var(--color-primary); + background: var(--color-interaction-hover-bg); + font-size: 0.85rem; + overflow-wrap: anywhere; +} + +.document-editor__upload-placeholder::before { + content: ""; + width: 0.8rem; + height: 0.8rem; + flex-shrink: 0; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: editor-upload-spin 0.8s linear infinite; +} + +.document-editor__upload-notice { + position: fixed; + inset: auto 1rem 1rem auto; + z-index: 120; + max-width: min(28rem, calc(100vw - 2rem)); + margin: 0; + padding: 0.75rem 1rem; + border: 1px solid currentColor; + border-radius: 0.65rem; + background: var(--color-surface); + color: var(--color-primary); + font-size: 0.85rem; +} + +.document-editor__upload-notice[data-error="true"] { color: var(--color-danger); } +.document-editor__upload-notice button:not([hidden]) { display: block; margin-top: 0.5rem; } +@keyframes editor-upload-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { + .document-editor__upload-placeholder::before { animation: none; } +} diff --git a/engine/app/controllers/coplan/api/v1/attachments_controller.rb b/engine/app/controllers/coplan/api/v1/attachments_controller.rb index 95b2a565..236039ad 100644 --- a/engine/app/controllers/coplan/api/v1/attachments_controller.rb +++ b/engine/app/controllers/coplan/api/v1/attachments_controller.rb @@ -37,7 +37,7 @@ def create ) if result.success? - render json: attachment_json(result.attachment), status: :created + render json: attachment_json(result.attachment).merge(reused: result.reused?), status: :created else render json: { error: result.error }, status: :unprocessable_content end diff --git a/engine/app/controllers/coplan/attachments_controller.rb b/engine/app/controllers/coplan/attachments_controller.rb index 0b95e71d..04c67146 100644 --- a/engine/app/controllers/coplan/attachments_controller.rb +++ b/engine/app/controllers/coplan/attachments_controller.rb @@ -10,22 +10,40 @@ def create files = Array(params[:files]).reject(&:blank?) if files.empty? return respond_to do |format| + format.json { render json: { attachments: [], errors: [ "Choose at least one file to upload." ] }, status: :unprocessable_content } format.turbo_stream { render turbo_stream: toast_stream("Choose at least one file to upload.", "alert") } format.html { redirect_to helpers.plan_browse_path(@plan, anchor: "footnote-attachments"), alert: "Choose at least one file to upload." } end end errors = [] + attachments = [] files.each do |file| result = Plans::AddAttachment.call(plan: @plan, file: file, user: current_user) - errors << "#{file.original_filename}: #{result.error}" unless result.success? + if result.success? + blob = result.attachment.blob + url = main_app.rails_blob_path(blob, only_path: true) + attachments << { id: result.attachment.id, filename: blob.filename.to_s, + content_type: blob.content_type, url: url, reused: result.reused?, + download_url: main_app.rails_blob_path(blob, disposition: :attachment, only_path: true) } + else + errors << "#{file.original_filename}: #{result.error}" + end end - uploaded = files.size - errors.size - notice = "#{uploaded} #{"file".pluralize(uploaded)} uploaded." if uploaded.positive? + uploaded = attachments.count { |attachment| !attachment[:reused] } + reused = attachments.size - uploaded + notice = [] + notice << "#{uploaded} #{"file".pluralize(uploaded)} uploaded." if uploaded.positive? + notice << "#{reused} existing #{"file".pluralize(reused)} reused." if reused.positive? + notice = notice.join(" ").presence alert = errors.join(" ") if errors.any? respond_to do |format| + format.json do + render json: { attachments: attachments, errors: errors, streams: attachment_update_streams.map(&:to_s).join }, + status: attachments.any? ? :created : :unprocessable_content + end # In-place: swap the attachments section where it stands and toast — # never bounce the reader to the top of the plan they're in. format.turbo_stream { render_attachments_update(notice: notice, alert: alert) } @@ -72,8 +90,15 @@ def set_plan # message — the whole point is that the reader's scroll position never # moves. def render_attachments_update(notice: nil, alert: nil) + streams = attachment_update_streams + streams << toast_stream(notice, "notice") if notice + streams << toast_stream(alert, "alert") if alert + render turbo_stream: streams + end + + def attachment_update_streams attachments = @plan.attachments_attachments.includes(:blob).order(created_at: :desc) - streams = [ + [ turbo_stream.replace("plan-attachments", partial: "coplan/plans/attachments", locals: { plan: @plan, attachments: attachments }), @@ -82,9 +107,6 @@ def render_attachments_update(notice: nil, alert: nil) count_stream("attachments-count", attachments.size), count_stream("nav-attachments-count", attachments.size) ] - streams << toast_stream(notice, "notice") if notice - streams << toast_stream(alert, "alert") if alert - render turbo_stream: streams end def count_stream(id, count) diff --git a/engine/app/controllers/coplan/plans_controller.rb b/engine/app/controllers/coplan/plans_controller.rb index abdb2eb3..5602e56e 100644 --- a/engine/app/controllers/coplan/plans_controller.rb +++ b/engine/app/controllers/coplan/plans_controller.rb @@ -239,7 +239,8 @@ def create edit_url = helpers.plan_browse_path(plan, edit: "continue") render json: editor_snapshot.merge(edit_url: edit_url, inline_after_create: true, id: plan.id, subscription_html: helpers.turbo_stream_from(plan), - update_url: update_content_plan_path(plan), state_url: editor_state_plan_path(plan), lease_url: editor_lease_plan_path(plan)) + update_url: update_content_plan_path(plan), state_url: editor_state_plan_path(plan), lease_url: editor_lease_plan_path(plan), + attachments_url: plan_attachments_path(plan)) rescue Plans::CreateHumanDraft::InvalidKey => error render json: { error: error.message }, status: :unprocessable_content rescue Plans::CreateHumanDraft::PlacementFailed => error diff --git a/engine/app/javascript/controllers/coplan/editor_controller.js b/engine/app/javascript/controllers/coplan/editor_controller.js index afcd0ed9..3222facc 100644 --- a/engine/app/javascript/controllers/coplan/editor_controller.js +++ b/engine/app/javascript/controllers/coplan/editor_controller.js @@ -5,8 +5,8 @@ import { commandFor } from "coplan/shortcuts" // sent snapshot and edits typed while it was in flight. Nothing clears a draft // until the server has acknowledged that exact content. export default class extends Controller { - static targets = ["textarea", "surface", "status", "statusText", "statusAnnouncement", "back", "rawSurface", "toolbar", "formatControls", "moreTools", "newLanguage", "codePicker", "contentPicker", "codeOption", "draftNotice", "legacyDraftNotice", "conflict", "error", "retry", "review", "replace", "latest", "style", "subscription", "citationDialog", "citationBody", "citationTitle", "citationError", "citationSave"] - static values = { planId: String, userId: String, draftScope: String, revision: Number, stateUrl: String, tokenUrl: String, previewUrl: String, leaseUrl: String, inline: Boolean, embedDomains: Array } + static targets = ["textarea", "surface", "status", "statusText", "statusAnnouncement", "back", "rawSurface", "toolbar", "formatControls", "moreTools", "newLanguage", "codePicker", "contentPicker", "codeOption", "draftNotice", "legacyDraftNotice", "conflict", "error", "retry", "review", "replace", "latest", "style", "subscription", "citationDialog", "citationBody", "citationTitle", "citationError", "citationSave", "uploadNotice", "uploadMessage", "uploadDismiss"] + static values = { planId: String, userId: String, draftScope: String, revision: Number, stateUrl: String, tokenUrl: String, previewUrl: String, leaseUrl: String, attachmentsUrl: String, inline: Boolean, embedDomains: Array } revisionValueChanged(revision) { if (this.inlineValue) this.element.dispatchEvent(new CustomEvent("coplan:editor-revision", { bubbles: true, detail: { revision } })) @@ -36,7 +36,8 @@ export default class extends Controller { this.mode = "rich" this.richEditor = rich.createRichDocument(this.surfaceTarget, this.textareaTarget.value, (content, kind) => this.editorChanged("rich", content, kind), state => this.updateToolbar(state), source => this.preview(source), - this.inlineValue ? this.comments() : [], { documentTitle: true }) + this.inlineValue ? this.comments() : [], { documentTitle: true, + uploadFiles: files => this.uploadFiles(files), uploadError: message => this.uploadNotice(message, true) }) if (this.inlineValue) { const threads = document.getElementById("plan-threads") if (threads) { @@ -84,15 +85,77 @@ export default class extends Controller { clearTimeout(this.saveTimer) clearTimeout(this.refreshTimer) clearTimeout(this.compositionTimer) + clearTimeout(this.uploadNoticeTimer) this.persistDraft() this.richEditor?.destroy() this.rawEditor?.destroy() + for (const abort of this.uploadRequests || []) abort.abort() this.noteToast?.remove() this.releaseIdle() this.releaseComposition() } get isNew() { return this.planIdValue === "new" } + uploadNotice(message, error = false) { + if (!this.active) return + clearTimeout(this.uploadNoticeTimer) + if (error) (this.uploadErrors ||= new Set()).add(message) + // Progress and later successes must not erase a rejected file. Keep its + // explanation until the author dismisses it, including while leaving. + if (this.uploadErrors?.size) { message = Array.from(this.uploadErrors).join(" "); error = true } + this.uploadNoticeTarget.hidden = false + this.uploadMessageTarget.textContent = message + this.uploadDismissTarget.hidden = !error + this.uploadNoticeTarget.dataset.error = String(error) + this.uploadNoticeTarget.setAttribute("role", error ? "alert" : "status") + this.uploadNoticeTarget.showPopover() + if (!error && !message.startsWith("Uploading ")) this.uploadNoticeTimer = setTimeout(() => { + this.uploadNoticeTarget.hidePopover() + this.uploadNoticeTarget.hidden = true + }, 6000) + } + dismissUploadErrors() { + this.uploadErrors?.clear() + this.uploadNoticeTarget.hidePopover() + this.uploadNoticeTarget.hidden = true + } + async uploadFiles(files) { + this.uploadNotice(`Uploading ${files.length === 1 ? files[0].name : `${files.length} files`}…`) + // The normal creation key makes this safe even when a previous create + // response was lost. Wait for creation before attaching to the new plan. + if (this.isNew && !await this.flush(true)) throw new Error("Save this document before uploading. Your files have not been uploaded; drop them again to retry.") + if (!this.active) return [] + const body = new FormData() + for (const file of files) body.append("files[]", file) + const abort = new AbortController() + ;(this.uploadRequests ||= new Set()).add(abort) + const timer = setTimeout(() => abort.abort(), 120000) + try { + const response = await fetch(this.attachmentsUrlValue, { method: "POST", body, signal: abort.signal, + headers: { Accept: "application/json", "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content } }) + if (response.redirected || response.status === 401) throw new Error("Sign in again in another tab, then drop the files again to retry.") + if (!response.headers.get("content-type")?.includes("application/json")) throw new Error("Could not upload files. Check your connection or sign in again, then drop them again to retry.") + const result = await response.json() + if (!response.ok && response.status !== 422) throw new Error(result.error || "Could not upload files. Drop them again to retry.") + if (!this.active) return [] + if (result.streams) window.Turbo?.renderStreamMessage(result.streams) + const errors = result.errors || [] + const reused = result.attachments.filter(file => file.reused).length + const uploaded = result.attachments.length - reused + const messages = [] + if (uploaded) messages.push(`${uploaded} ${uploaded === 1 ? "file" : "files"} uploaded.`) + if (reused) messages.push(`${reused} existing ${reused === 1 ? "file" : "files"} reused.`) + this.uploadNotice(errors.length ? `${errors.join(" ")} Drop rejected files again to retry.` : + messages.join(" "), errors.length > 0) + return result.attachments + } catch (error) { + if (error.name === "AbortError") throw new Error("Upload interrupted. Check Attachments before dropping the files again to retry.") + throw new Error(`${error.message} Check Attachments before dropping the files again to retry.`) + } finally { + clearTimeout(timer) + this.uploadRequests.delete(abort) + } + } templateNoteRejected() { this.noteToast?.remove() const toast = document.createElement("div") @@ -220,6 +283,7 @@ export default class extends Controller { this.element.action = result.update_url this.stateUrlValue = result.state_url this.leaseUrlValue = result.lease_url + this.attachmentsUrlValue = result.attachments_url if (this.active) { if (!result.inline_after_create) window.history.replaceState(window.history.state, "", result.edit_url) this.subscriptionTarget.innerHTML = result.subscription_html @@ -234,7 +298,7 @@ export default class extends Controller { this.setStatus(newerChanges ? (this.inlineValue ? "Changes ready to save" : "Saved · newer changes waiting") : `All changes saved · v${result.revision}`, newerChanges ? "queued" : "saved") this.persistDraft() - if (this.pendingInlineVisitUrl && !newerChanges && this.active) { + if (this.pendingInlineVisitUrl && !newerChanges && this.active && !this.richEditor?.uploading && !this.uploadErrors?.size) { const url = this.pendingInlineVisitUrl this.pendingInlineVisitUrl = null this.leaving = true @@ -463,7 +527,7 @@ export default class extends Controller { if (commandFor("editor", event) === "save") { event.preventDefault(); this.flush(true) } } beforeUnload(event) { - if (!this.active || !this.dirty()) return + if (!this.active || (!this.dirty() && !this.richEditor?.uploading)) return this.persistDraft() event.preventDefault() event.returnValue = "" @@ -481,23 +545,32 @@ export default class extends Controller { if (this.navigating) return this.navigating = true try { - await this.whenIdle() - if (!this.active || this.blocked) return - if (this.isNew && this.textareaTarget.value === this.base.content) { + // Any wait can admit another upload or edit. Reach one shared idle + // point before freezing the form, rather than draining each queue once. + while (true) { + await this.richEditor?.whenUploadsIdle() + await this.whenIdle() + await this.whenCompositionEnds() + if (!this.active || this.blocked) return + if (this.richEditor?.uploading || this.busy) continue + if (this.uploadErrors?.size) return + if (this.isNew && this.textareaTarget.value === this.base.content) { + this.element.dispatchEvent(new CustomEvent("coplan:editor-closed", { bubbles: true, + detail: { controller: this, snapshot: { ...this.base } } })) + return + } + if (!this.element.checkValidity()) this.revealDetails() + if (!this.element.reportValidity()) return + if (this.dirty()) { + if (!await this.flush(true) || !this.active || this.blocked) return + continue + } + this.element.inert = true + if (!await this.flush(true, null, true) || !this.active || this.blocked) return this.element.dispatchEvent(new CustomEvent("coplan:editor-closed", { bubbles: true, detail: { controller: this, snapshot: { ...this.base } } })) return } - if (!this.element.checkValidity()) this.revealDetails() - if (!this.element.reportValidity()) return - while (this.dirty()) { - if (!await this.flush(true) || !this.active || this.blocked) return - } - await this.whenCompositionEnds() - this.element.inert = true - if (!await this.flush(true, null, true) || !this.active || this.blocked) return - this.element.dispatchEvent(new CustomEvent("coplan:editor-closed", { bubbles: true, - detail: { controller: this, snapshot: { ...this.base } } })) } finally { this.element.inert = false; this.navigating = false } } showInline(anchor, { focus = true } = {}) { @@ -517,7 +590,7 @@ export default class extends Controller { beforeVisit(event) { // A failed sync should not block unrelated navigation. disconnect() // retains the draft if the author chooses to return to it. - if (this.leaving || this.retryable || this.blocked) return + if (this.leaving || ((this.retryable || this.blocked) && !this.richEditor?.uploading && !this.uploadErrors?.size)) return event.preventDefault() this.navigate(() => event.detail.url) } @@ -528,28 +601,33 @@ export default class extends Controller { this.navigating = true this.backTarget.setAttribute("aria-busy", "true") try { - if (this.busy || this.dirty()) this.setStatus("Saving before going back…", "saving") - await this.whenIdle() - if (!this.active) return - if (this.isNew && this.textareaTarget.value === this.base.content) { + while (true) { + await this.richEditor?.whenUploadsIdle() + if (this.busy || this.dirty()) this.setStatus("Saving before going back…", "saving") + await this.whenIdle() + await this.whenCompositionEnds() + if (!this.active) return + if (this.richEditor?.uploading || this.busy) continue + if (this.uploadErrors?.size) return + if (this.isNew && this.textareaTarget.value === this.base.content) { + this.leaving = true + window.Turbo.visit(destination()) + return + } + if ((!this.isNew || this.dirty()) && !this.element.reportValidity()) { this.setStatus("Correct the highlighted field before going back", "error"); return } + if (this.blocked) { this.setStatus("Resolve the conflict before going back · draft retained", "error"); return } + if (this.dirty()) { + if (!await this.flush(true) || !this.active || this.blocked) return + continue + } + this.element.inert = true + if (finalize && (!await this.flush(true, null, true) || !this.active || this.blocked)) return this.leaving = true + // Do not preview a reading-page snapshot taken before this save. + window.Turbo.cache.clear() window.Turbo.visit(destination()) return } - if ((!this.isNew || this.dirty()) && !this.element.reportValidity()) { this.setStatus("Correct the highlighted field before going back", "error"); return } - if (this.blocked) { this.setStatus("Resolve the conflict before going back · draft retained", "error"); return } - while (this.dirty()) { - if (!await this.flush(true) || !this.active || this.blocked) return - } - if (finalize) { - await this.whenCompositionEnds() - this.element.inert = true - if (!await this.flush(true, null, true) || !this.active || this.blocked) return - } - this.leaving = true - // Do not preview a reading-page snapshot taken before this save. - window.Turbo.cache.clear() - window.Turbo.visit(destination()) } finally { this.element.inert = false this.navigating = false diff --git a/engine/app/javascript/coplan/rich_document.js b/engine/app/javascript/coplan/rich_document.js index 8babfccf..86d60ca0 100644 --- a/engine/app/javascript/coplan/rich_document.js +++ b/engine/app/javascript/coplan/rich_document.js @@ -1,5 +1,6 @@ import { diffArrays } from "diff" import { codeHighlight } from "coplan/code_highlight" +import { richUploads } from "coplan/rich_uploads" import { textHunks } from "coplan/merge_text" import { citationLabelKey } from "coplan/citation_labels" import { enableLetteredLists, letterOrder, letterMarker } from "coplan/lettered_lists" @@ -872,7 +873,8 @@ function documentTitlePlugin() { }) } -export function createRichDocument(element, markdown, changed, selectionChanged = () => {}, preview = null, comments = [], { documentTitle = false } = {}) { +export function createRichDocument(element, markdown, changed, selectionChanged = () => {}, preview = null, comments = [], { documentTitle = false, uploadFiles = null, uploadError = () => {} } = {}) { + const uploads = uploadFiles ? richUploads(schema, uploadFiles, uploadError, documentTitle) : null let rangeDoc, ranges, previewOffsetTimer, editingLanguage = false const sourceRangeAt = (view, position) => { if (rangeDoc !== view.state.doc) { @@ -892,7 +894,7 @@ export function createRichDocument(element, markdown, changed, selectionChanged } const state = EditorState.create({ doc: parseDocument(markdown), - plugins: [...(documentTitle ? [documentTitlePlugin(), keymap({ + plugins: [...(uploads ? [uploads.plugin] : []), ...(documentTitle ? [documentTitlePlugin(), keymap({ Backspace: titleBoundary("Backspace"), Delete: titleBoundary("Delete"), Enter: titleEnter, "Shift-Enter": titleEnter })] : []), history(), inputRules({ rules: [ @@ -990,6 +992,7 @@ export function createRichDocument(element, markdown, changed, selectionChanged } }, handlePaste(view, event) { + if (event.clipboardData?.files.length) return false if (view.state.selection.$from.parent.type.spec.code) return false const source = event.clipboardData?.getData("application/x-coplan-markdown") || event.clipboardData?.getData("text/plain") if (!source || !/(^|\n)```[^\n]*\n|^\s*\|[^\n]+\|\s*\n\s*\|?\s*:?-{3,}|^ {0,3}/m.test(source)) return false @@ -1045,6 +1048,8 @@ export function createRichDocument(element, markdown, changed, selectionChanged }) return { view, + get uploading() { return uploads?.uploading || false }, + whenUploadsIdle: () => uploads?.whenIdle() || Promise.resolve(), content: () => serializeDocument(view.state.doc), focusTitle() { view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 1, view.state.doc.firstChild.nodeSize - 1))) diff --git a/engine/app/javascript/coplan/rich_uploads.js b/engine/app/javascript/coplan/rich_uploads.js new file mode 100644 index 00000000..6cc654da --- /dev/null +++ b/engine/app/javascript/coplan/rich_uploads.js @@ -0,0 +1,101 @@ +import { Fragment, Slice } from "prosemirror-model" +import { Plugin, PluginKey } from "prosemirror-state" +import { Decoration, DecorationSet } from "prosemirror-view" +import { closeHistory } from "prosemirror-history" + +// Upload markers live outside the document and map through every local/remote +// transaction. Neither a pending upload nor its failure can enter saved Markdown. +export function richUploads(schema, uploadFiles, reportError, documentTitle) { + const key = new PluginKey("coplan-uploads"), pending = new Set() + let destroyed = false + const hasFiles = event => Array.from(event.dataTransfer?.types || []).includes("Files") + const clearDrag = view => view.dom.classList.remove("is-file-drop-target") + function start(view, files, position) { + const id = crypto.randomUUID(), $pos = view.state.doc.resolve(position) + if (documentTitle && position < view.state.doc.firstChild.nodeSize) position = view.state.doc.firstChild.nodeSize + else if ($pos.parent.type.spec.code || $pos.parent.type.name === "template_note") position = $pos.after() + const marker = document.createElement("span") + marker.className = "document-editor__upload-placeholder" + marker.setAttribute("role", "status") + marker.textContent = `Uploading ${files.map(file => file.name).join(", ")}…` + view.dispatch(view.state.tr.setMeta(key, { add: Decoration.widget(position, marker, { id, side: -1 }) })) + const task = (async () => { + try { + const attachments = await uploadFiles(files) + if (destroyed) return + const placeholder = key.getState(view.state).find().find(item => item.spec.id === id) + if (!placeholder) { + if (attachments.length) reportError("Files uploaded to Attachments. Their insertion point was removed; copy their Markdown there to embed them.") + return + } + const blocks = attachments.map(file => schema.nodes.paragraph.create(null, + file.content_type.startsWith("image/") ? schema.nodes.image.create({ src: file.url, alt: file.filename }) : + schema.text(file.filename, [schema.marks.link.create({ href: file.download_url })]))) + const tr = view.state.tr.setMeta(key, { remove: id }) + if (blocks.length) { + tr.replaceRange(placeholder.from, placeholder.from, new Slice(Fragment.fromArray(blocks), 0, 0)) + closeHistory(tr).setMeta("coplan-input-origin", "unknown") + } + // Keep the author's current caret and viewport; the drop location may + // now be offscreen because they continued writing during the upload. + view.dispatch(tr) + if (blocks.length) view.dispatch(closeHistory(view.state.tr)) + } catch (error) { + if (!destroyed) reportError(`${files.map(file => file.name).join(", ")}: ${error.message || "Upload failed. Drop the files again to retry."}`) + } finally { + if (!destroyed) view.dispatch(view.state.tr.setMeta(key, { remove: id })) + } + })() + pending.add(task) + task.finally(() => pending.delete(task)) + } + return { + get uploading() { return pending.size > 0 }, + async whenIdle() { while (pending.size) await Promise.all(pending) }, + plugin: new Plugin({ + key, + state: { + init: () => DecorationSet.empty, + apply(tr, markers) { + markers = markers.map(tr.mapping, tr.doc) + const action = tr.getMeta(key) + if (action?.add) markers = markers.add(tr.doc, [action.add]) + if (action?.remove) markers = markers.remove(markers.find().filter(item => item.spec.id === action.remove)) + return markers + } + }, + props: { + decorations: state => key.getState(state), + handleDOMEvents: { + dragover(view, event) { + if (!hasFiles(event) || view.dragging) return false + event.preventDefault() + event.dataTransfer.dropEffect = "copy" + view.dom.classList.add("is-file-drop-target") + return true + }, + dragleave(view, event) { + if (!view.dom.contains(event.relatedTarget)) clearDrag(view) + return false + }, + drop(view) { clearDrag(view); return false } + }, + handleDrop(view, event, slice, moved) { + const files = Array.from(event.dataTransfer?.files || []) + if (!files.length || moved || view.dragging) return false + const position = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos + if (position == null) return false + start(view, files, position) + return true + }, + handlePaste(view, event) { + const files = Array.from(event.clipboardData?.files || []) + if (!files.length) return false + start(view, files, view.state.selection.from) + return true + } + }, + view: () => ({ destroy() { destroyed = true } }) + }) + } +} diff --git a/engine/app/services/coplan/plans/add_attachment.rb b/engine/app/services/coplan/plans/add_attachment.rb index fe7683c5..3bfb912f 100644 --- a/engine/app/services/coplan/plans/add_attachment.rb +++ b/engine/app/services/coplan/plans/add_attachment.rb @@ -12,8 +12,9 @@ module Plans # blob is created so rejected uploads never write to the storage service; # the Plan model validation acts as a backstop for any other attach path. class AddAttachment - Result = Struct.new(:attachment, :error, keyword_init: true) do + Result = Struct.new(:attachment, :error, :reused, keyword_init: true) do def success? = error.nil? + def reused? = reused == true end def self.call(**kwargs) @@ -33,21 +34,41 @@ def initialize(plan:, file:, user:, actor_type: nil, actor_id: nil, agent_name: def call error = validate_file return Result.new(error: error) if error + return Result.new(error: "Save plan changes before uploading files") if @plan.has_changes_to_save? + + io = @file.open + candidate = ActiveStorage::Blob.build_after_unfurling(io: io, + filename: @file.original_filename, content_type: @file.content_type) + existing = matching_attachment(candidate.checksum, candidate.byte_size) + return Result.new(attachment: existing, reused: true) if existing blob = ActiveStorage::Blob.create_and_upload!( - io: @file.open, + io: io, filename: @file.original_filename, content_type: @file.content_type, metadata: uploader_metadata ) + # Upload outside the row lock so storage latency does not block edits. + # Recheck under the lock: another upload may have attached these bytes + # while this one was in flight. Purge the unused blob after the lock. + result = @plan.with_lock do + existing = matching_attachment(blob.checksum, blob.byte_size, lock: true) + existing ? Result.new(attachment: existing, reused: true) : attach_blob(blob) + end + blob.purge if result.attachment&.blob_id != blob.id + result + end + + private + + def attach_blob(blob) # `attach` on a persisted, unchanged record saves the plan, which runs # the model backstop validation. If content-type sniffing (blob # identification) reclassified the file into a disallowed type, the # save fails — purge the orphaned blob and surface the validation # error. Success is judged by the attachment row actually being - # persisted (not just attach's return value), so a dirty plan record - # that skips the save path is also treated as a failure. + # persisted (not just attach's return value). @plan.attachments.attach(blob) attachment = @plan.attachments.attachments.find { |a| a.blob_id == blob.id && a.persisted? } @@ -55,7 +76,6 @@ def call error = @plan.errors.full_messages.to_sentence.presence || "Could not attach file" @plan.errors.clear @plan.attachment_changes.delete("attachments") - blob.purge return Result.new(error: error) end @@ -74,7 +94,15 @@ def call Result.new(attachment: attachment) end - private + def matching_attachment(checksum, byte_size, lock: false) + scope = @plan.attachments_attachments.joins(:blob).includes(:blob) + .where(active_storage_blobs: { checksum: checksum, byte_size: byte_size, content_type: Plan::ATTACHMENT_CONTENT_TYPES }) + .order(:created_at, :id) + scope = scope.lock if lock + # Some hosts use case-insensitive collations. Base64 checksums must + # still match exactly, regardless of the database's text comparison. + scope.detect { |attachment| attachment.blob.checksum == checksum } + end def validate_file return "file is required" if @file.blank? || !@file.respond_to?(:original_filename) diff --git a/engine/app/views/coplan/agent_instructions/guides/api.text.erb b/engine/app/views/coplan/agent_instructions/guides/api.text.erb index cf6b5fc0..35a79eb1 100644 --- a/engine/app/views/coplan/agent_instructions/guides/api.text.erb +++ b/engine/app/views/coplan/agent_instructions/guides/api.text.erb @@ -66,12 +66,19 @@ Agents can read reactions but cannot add them. There is no reactions endpoint. ## References and attachments +The browser editor uses session-authenticated multipart `POST /plans/:id/attachments` +with `files[]` and `Accept: application/json`. It returns `attachments` (id, filename, +content_type, url, download_url, reused), per-file `errors`, and browser Turbo `streams`. +Status is 201 when any file succeeds, including partial success, or 422 when none +succeed. Browser draft creation returns `attachments_url` for uploads. +Agents use the `/api/v1` routes below with their API credential. + | Method and path | Purpose | Guide | |---|---|---| | `GET`, `POST /api/v1/plans/:id/references` | List or add references. Adding is for the plan author only. | creating | | `DELETE /api/v1/plans/:id/references/:reference_id` | Remove a reference. Plan author only. | creating | | `GET /api/v1/references/search?url=` | Find the plans that refer to a URL. | creating | -| `GET`, `POST /api/v1/plans/:id/attachments` | List or upload files (multipart `file`). Uploading is for the plan author only. | markdown | +| `GET`, `POST /api/v1/plans/:id/attachments` | List or upload files (multipart `file`). Uploading is for the plan author only. POST returns 201 with `reused: true` for identical contents already attached to this plan, or `false` for a new file. | markdown | | `DELETE /api/v1/plans/:id/attachments/:attachment_id` | Delete a file. Plan author only. | markdown | ## Review requests and decisions diff --git a/engine/app/views/coplan/agent_instructions/guides/markdown.text.erb b/engine/app/views/coplan/agent_instructions/guides/markdown.text.erb index da54f1bb..bf636d86 100644 --- a/engine/app/views/coplan/agent_instructions/guides/markdown.text.erb +++ b/engine/app/views/coplan/agent_instructions/guides/markdown.text.erb @@ -196,6 +196,12 @@ The web editor writes this form when a person resizes an image. Snapping back to Images pasted from other apps default to natural size within the document width, ignoring copied display widths. Copying images within Coplan's rich editor preserves their saved widths. Existing Markdown widths remain unchanged. +People can drop image or file uploads into the rich editor, or paste image files. Images become Markdown images; other files become download links. Uploads stay in Attachments after Undo removes their embedded content. New documents are created before their first upload. Embedding uses the normal human save and versioning rules. + +Failed or rejected editor uploads keep an error notice until the person dismisses it. Done and navigation keep the editor open until then. Accepted files and writing still save normally. + +Uploading identical contents to the same plan returns its existing attachment, even with a different filename. The original filename, uploader, and URLs stay unchanged. The upload response includes `reused: true`; a new file returns `reused: false`. Both return 201. Reuse creates no new attachment-added event. Different contents stay separate, and files are never reused across plans. Each editor drop still inserts an embed. Existing duplicates are not removed. + When a paragraph starts with an image, text in that paragraph wraps beside it from the top if there is room. Put a blank line before the next paragraph to start below the image. A hard line break stays beside the image. In the web editor, Enter starts below; Shift+Enter adds a line beside it. - The maximum size is 25 MB per file. Allowed types are PNG, JPEG, GIF, WebP, PDF, TXT, MD, CSV, JSON, and ZIP. Coplan returns `422` for other files. diff --git a/engine/app/views/coplan/plans/_editor_form.html.erb b/engine/app/views/coplan/plans/_editor_form.html.erb index 415810c9..b3b22eea 100644 --- a/engine/app/views/coplan/plans/_editor_form.html.erb +++ b/engine/app/views/coplan/plans/_editor_form.html.erb @@ -9,6 +9,7 @@ "coplan--editor-state-url-value": creating ? "" : editor_state_plan_path(@plan), "coplan--editor-token-url-value": creating ? new_plan_path : "", "coplan--editor-lease-url-value": creating ? "" : editor_lease_plan_path(@plan), + "coplan--editor-attachments-url-value": creating ? "" : plan_attachments_path(@plan), "coplan--editor-embed-domains-value": CoPlan::EmbedDomain.order(:hostname).pluck(:hostname).to_json, "coplan--editor-preview-url-value": preview_draft_plans_path } do %> <% if creating %> @@ -138,6 +139,10 @@ <% CoPlan.configuration.editor_code_languages.each do |language| %> +
data-action="mouseup->coplan--text-selection#handleEditorMouseUp"<% end %>>
diff --git a/engine/config/importmap.rb b/engine/config/importmap.rb index 96a920b9..0385da67 100644 --- a/engine/config/importmap.rb +++ b/engine/config/importmap.rb @@ -28,6 +28,7 @@ pin "prosemirror-schema-list", to: "https://esm.sh/prosemirror-schema-list@1.5.1?bundle&external=prosemirror-model,prosemirror-state,prosemirror-view,prosemirror-transform,prosemirror-commands,prosemirror-keymap,prosemirror-history,prosemirror-schema-list,prosemirror-markdown", preload: false pin "prosemirror-markdown", to: "https://esm.sh/prosemirror-markdown@1.13.2?bundle&external=prosemirror-model,prosemirror-state,prosemirror-view,prosemirror-transform,prosemirror-commands,prosemirror-keymap,prosemirror-history,prosemirror-schema-list,prosemirror-markdown", preload: false pin "coplan/rich_document", to: "coplan/rich_document.js", preload: false +pin "coplan/rich_uploads", to: "coplan/rich_uploads.js", preload: false pin "coplan/lettered_lists", to: "coplan/lettered_lists.js", preload: false pin "coplan/citation_labels", to: "coplan/citation_labels.js", preload: false pin "coplan/viewport_anchor", to: "coplan/viewport_anchor.js", preload: false diff --git a/engine/db/demo/content/file-uploads.md b/engine/db/demo/content/file-uploads.md new file mode 100644 index 00000000..f254f53e --- /dev/null +++ b/engine/db/demo/content/file-uploads.md @@ -0,0 +1,13 @@ +Open **Edit** and drop an image and a text file into the document. Images appear inline; other supported files become download links. You can also paste an image file from your clipboard. + +## Drop files here + +Drop the same file twice. Each drop inserts an embed, but **Attachments** keeps one stored copy and the editor says the existing file was reused. Rename the local file and drop it again: identical contents keep the original filename, uploader, and URL. + +Different contents stay separate, even with the same filename. Reuse is limited to this document. Previously stored duplicates are preserved so their saved links keep working. + +## Keep writing + +Continue typing during an upload. Its marker stays at the drop location. **Done** waits for uploads and saves the embedded content. **Dual** shows the image or link in Markdown. **Undo** removes an embed while leaving the file in Attachments for reuse. + +Try images, PDF, text, Markdown, CSV, JSON, or ZIP files up to 25 MB each. Unsupported files show an error without discarding your writing or the accepted files. If a file fails while **Done** or navigation is waiting, the editor stays open. Retry the file as needed, then **Dismiss** the error before leaving. A later successful upload keeps earlier errors visible until dismissed. diff --git a/engine/db/demo/content/image-resizing.md b/engine/db/demo/content/image-resizing.md index 24922cc4..ce362b83 100644 --- a/engine/db/demo/content/image-resizing.md +++ b/engine/db/demo/content/image-resizing.md @@ -15,3 +15,11 @@ Both images keep their proportions. **Undo** reverses one resize at a time. Pres ## Pasting images Paste an image copied from another app: it starts at its natural size within the document width, even if the other app displayed a thumbnail. Copy and paste the saved-width image within Coplan's rich editor to keep its intentional size. Existing saved widths stay as you set them. + +## Dropping files into the editor + +Open **Edit** and drop an image or file at a spot in the document. Images upload and appear inline at natural size; PDFs, text files, and other supported files become download links. You can also paste an image file from your clipboard. Try dropping an image and a text file together. + +An uploading marker stays at the drop point while you keep writing. **Done** waits for uploads and saves the embedded content. **Undo** removes the inserted content; the uploaded files remain in **Attachments** so you can reuse them. Files must be a supported type and no larger than 25 MB each. A rejected file shows an explanation without discarding the other files or your writing. + +Drop the same file twice. Each drop inserts an embed, but **Attachments** keeps one stored copy and the editor says the existing file was reused. Identical contents reuse the original filename and uploader even if you rename the local file. Different contents stay separate, including files with the same name. Reuse is limited to this document. diff --git a/engine/lib/coplan/development_seed.rb b/engine/lib/coplan/development_seed.rb index 53eb0526..12fa0593 100644 --- a/engine/lib/coplan/development_seed.rb +++ b/engine/lib/coplan/development_seed.rb @@ -121,7 +121,10 @@ module DevelopmentSeed showcase: "A realistic agent-drafted design doc for judging reading: common Markdown elements, SQL, Ruby, YAML, and diff code, a long scrolling line, two Mermaid diagrams, and open, answered, resolved, diagram, table-cell, and general discussions" }, { key: "image-resizing", author: "hampton", type: "General", title: "Images: resize and snap to full width", tags: %w[demo images editor], visibility: "published", folder: "Demo content/Rich text", group: "Tables and rich text", - showcase: "Editor corner resizing, text wrapping beside images, Enter to continue below, undo, and snap to natural full width" }, + showcase: "Drop or paste files to upload and embed; duplicate drops reuse existing files; image resizing, wrapping, undo, and snap to full width" }, + { key: "file-uploads", author: "hampton", type: "General", title: "Files: upload once, embed anywhere", + tags: %w[demo files editor], visibility: "published", folder: "Demo content/Rich text", group: "Tables and rich text", + showcase: "Drop images and files, repeat a drop to reuse storage, keep separate embeds, and undo without deleting attachments" }, { key: "callout-gallery", author: "hampton", type: "Design Doc", title: "Callouts: notes, tips, and warnings", tags: %w[demo markdown], visibility: "published", folder: "Demo content/Rich text", group: "Tables and rich text", showcase: "All six callout types including Summary, a custom label, a list and code inside a callout, and plain quotes for comparison; comment on callout text or edit it in Editor" }, diff --git a/spec/lib/development_seed_spec.rb b/spec/lib/development_seed_spec.rb index 7e005b1b..ade6aa2b 100644 --- a/spec/lib/development_seed_spec.rb +++ b/spec/lib/development_seed_spec.rb @@ -304,12 +304,35 @@ def seeded_plan(key) expect(CoPlan::Plan.count).to eq(0) end + it "seeds the file upload tryout and preserves uploaded files and embeds on reseeding" do + described_class.call + plan = seeded_plan("file-uploads") + expect(plan.current_content).to include("Drop the same file twice", "one stored copy", "**Done** waits for uploads") + expect(plan.current_content).to include("the editor stays open", "**Dismiss** the error", "earlier errors visible until dismissed") + expect(plan.attachments.count).to eq(0) + expect(seeded_plan("demo-library").current_content).to include("(/#{plan.url_path})") + file = Rack::Test::UploadedFile.new(Rails.root.join("spec/fixtures/files/sample.png"), "image/png") + uploaded = CoPlan::Plans::AddAttachment.call(plan: plan, file: file, user: plan.created_by_user) + expect(uploaded).to be_success + url = Rails.application.routes.url_helpers.rails_blob_path(uploaded.attachment.blob, only_path: true) + content = "#{plan.current_content}\n\n![sample.png](#{url})\n" + CoPlan::Plans::ReplaceContent.call(plan: plan, new_content: content, + base_revision: plan.current_revision, actor_type: "human", actor_id: plan.created_by_user_id) + revision = plan.current_revision + described_class.call + expect(plan.reload.current_revision).to eq(revision) + expect(plan.current_content).to eq(content) + expect(plan.attachments_attachments.pluck(:id)).to eq([ uploaded.attachment.id ]) + end + it "seeds image resizing once and preserves a person's saved image width" do described_class.call plan = seeded_plan("image-resizing") expect(plan.current_content).to include(described_class.demo_logo_url, 'width="320"', "![Coplan logo at natural width]") expect(plan.current_content).to include("This text starts at the top beside", "This paragraph deliberately starts below", "**Shift+Enter**", "**Enter**") expect(plan.current_content).to include("## Pasting images", "even if the other app displayed a thumbnail") + expect(plan.current_content).to include("## Dropping files into the editor", "download links", "**Done** waits for uploads", "remain in **Attachments**") + expect(plan.current_content).to include("Drop the same file twice", "one stored copy", "Different contents stay separate") expect(seeded_plan("demo-library").current_content).to include("(/#{plan.url_path})") revision = plan.current_revision described_class.call diff --git a/spec/requests/api/v1/attachments_spec.rb b/spec/requests/api/v1/attachments_spec.rb index 14b1bc6a..a3c0fe41 100644 --- a/spec/requests/api/v1/attachments_spec.rb +++ b/spec/requests/api/v1/attachments_spec.rb @@ -19,6 +19,19 @@ def upload_file(fixture = "sample.png", content_type = "image/png", headers: sel end describe "POST /api/v1/plans/:plan_id/attachments" do + it "returns the existing attachment and URLs on an identical retry" do + upload_file + original = response.parsed_body + expect(original["reused"]).to be(false) + upload_file + + expect(response).to have_http_status(:created) + expect(response.parsed_body).to include("reused" => true, "id" => original["id"], "url" => original["url"], "markdown" => original["markdown"]) + expect(plan.attachments.count).to eq(1) + expect(ActiveStorage::Blob.count).to eq(1) + expect(plan.plan_events.where(event_type: "attachment_added").count).to eq(1) + end + it "uploads a file and returns attachment JSON with URLs and markdown snippet" do expect { upload_file }.to change { plan.attachments_attachments.count }.by(1) diff --git a/spec/requests/attachments_spec.rb b/spec/requests/attachments_spec.rb index 378d4e09..9a5e50cc 100644 --- a/spec/requests/attachments_spec.rb +++ b/spec/requests/attachments_spec.rb @@ -6,6 +6,61 @@ let(:plan) { create(:plan, :considering, created_by_user: author) } describe "POST /plans/:plan_id/attachments" do + it "returns one attachment for repeated files in the same editor upload" do + sign_in_as(author) + post plan_attachments_path(plan), params: { + files: [ fixture_file_upload("sample.png", "image/png"), fixture_file_upload("sample.png", "image/png") ] + }, headers: { "Accept" => "application/json" } + + expect(response).to have_http_status(:created) + files = response.parsed_body["attachments"] + expect(files.map { |file| file["id"] }.uniq.size).to eq(1) + expect(files.map { |file| file["reused"] }).to eq([ false, true ]) + expect(plan.attachments.count).to eq(1) + expect(ActiveStorage::Blob.count).to eq(1) + end + + it "returns stable image and download URLs for editor uploads and refreshes attachment counts" do + sign_in_as(author) + post plan_attachments_path(plan), params: { + files: [ fixture_file_upload("sample.png", "image/png"), fixture_file_upload("sample.txt", "text/plain") ] + }, headers: { "Accept" => "application/json" } + + expect(response).to have_http_status(:created) + data = response.parsed_body + expect(data["errors"]).to eq([]) + expect(data["attachments"].map { |file| file["filename"] }).to eq(%w[sample.png sample.txt]) + expect(data["attachments"].first).to include("content_type" => "image/png", "url" => a_string_starting_with("/rails/active_storage/")) + expect(data["attachments"].last["download_url"]).to include("disposition=attachment") + expect(data["streams"]).to include('target="plan-attachments"', 'target="attachments-count"', 'target="nav-attachments-count"') + expect(plan.current_revision).to eq(1) # Embedding is a separate, ordinary editor save. + end + + it "returns successful files alongside per-file errors in a mixed editor upload" do + sign_in_as(author) + post plan_attachments_path(plan), params: { + files: [ fixture_file_upload("sample.html", "text/html"), fixture_file_upload("sample.png", "image/png") ] + }, headers: { "Accept" => "application/json" } + + expect(response).to have_http_status(:created) + expect(response.parsed_body["attachments"].map { |file| file["filename"] }).to eq([ "sample.png" ]) + expect(response.parsed_body["errors"]).to contain_exactly(a_string_including("sample.html", "not allowed")) + expect(plan.attachments.count).to eq(1) + end + + it "returns JSON validation errors when no files can be uploaded" do + sign_in_as(author) + post plan_attachments_path(plan), params: { files: [ fixture_file_upload("sample.html", "text/html") ] }, + headers: { "Accept" => "application/json" } + expect(response).to have_http_status(:unprocessable_content) + expect(response.parsed_body["attachments"]).to eq([]) + expect(response.parsed_body["errors"]).to contain_exactly(a_string_including("not allowed")) + + post plan_attachments_path(plan), headers: { "Accept" => "application/json" } + expect(response).to have_http_status(:unprocessable_content) + expect(response.parsed_body["errors"]).to eq([ "Choose at least one file to upload." ]) + end + it "uploads files for the plan author and logs events" do sign_in_as(author) diff --git a/spec/services/plans/add_attachment_concurrency_spec.rb b/spec/services/plans/add_attachment_concurrency_spec.rb new file mode 100644 index 00000000..460814c8 --- /dev/null +++ b/spec/services/plans/add_attachment_concurrency_spec.rb @@ -0,0 +1,47 @@ +require "rails_helper" + +RSpec.describe "Concurrent identical attachment uploads" do + self.use_transactional_tests = false + + after do + truncate_tables(*(ActiveRecord::Base.connection.tables - %w[schema_migrations ar_internal_metadata])) + end + + it "keeps one attachment, blob, stored file, and event when uploads finish together" do + user = create(:coplan_user) + plan = create(:plan, created_by_user: user) + ready, release = Queue.new, Queue.new + allow(ActiveStorage::Blob).to receive(:create_and_upload!).and_wrap_original do |method, **arguments| + blob = method.call(**arguments) + ready << blob + release.pop + blob + end + workers = 2.times.map do + Thread.new do + ActiveRecord::Base.connection_pool.with_connection do + file = Rack::Test::UploadedFile.new(Rails.root.join("spec/fixtures/files/sample.png"), "image/png") + CoPlan::Plans::AddAttachment.call(plan: CoPlan::Plan.find(plan.id), file: file, user: user) + end + end + end + blobs = Timeout.timeout(10) { 2.times.map { ready.pop } } + 2.times { release << true } + workers.each { |worker| expect(worker.join(20)).to be_present } + results = workers.map(&:value) + + expect(results).to all(be_success) + expect(results.map { |result| result.attachment.id }.uniq.size).to eq(1) + expect(results.count(&:reused?)).to eq(1) + expect(plan.attachments.count).to eq(1) + expect(ActiveStorage::Blob.count).to eq(1) + expect(blobs.count { |blob| blob.service.exist?(blob.key) }).to eq(1) + expect(plan.plan_events.where(event_type: "attachment_added").count).to eq(1) + ensure + 2.times { release << true } if release + workers&.each do |worker| + worker.kill if worker.alive? + worker.join + end + end +end diff --git a/spec/services/plans/add_attachment_spec.rb b/spec/services/plans/add_attachment_spec.rb index 8da7f77f..272062b1 100644 --- a/spec/services/plans/add_attachment_spec.rb +++ b/spec/services/plans/add_attachment_spec.rb @@ -36,6 +36,76 @@ def uploaded_file(fixture = "sample.png", content_type = "image/png") expect(event.actor_id).to eq("token-123") end + it "reuses identical content without another blob, storage upload, attachment, or event" do + original = described_class.call(plan: plan, file: uploaded_file, user: user) + expect(original).not_to be_reused + expect(ActiveStorage::Blob).not_to receive(:create_and_upload!) + + expect { + repeated = described_class.call(plan: plan, file: uploaded_file, user: user) + expect(repeated).to be_success + expect(repeated).to be_reused + expect(repeated.attachment).to eq(original.attachment) + }.not_to change(ActiveStorage::Blob, :count) + expect(plan.attachments.count).to eq(1) + expect(plan.plan_events.where(event_type: "attachment_added").count).to eq(1) + end + + it "reuses renamed content while keeping the original filename and uploader" do + original = described_class.call(plan: plan, file: uploaded_file, user: user) + renamed = Rack::Test::UploadedFile.new(Rails.root.join("spec/fixtures/files/sample.png"), + "image/png", original_filename: "renamed.png") + result = described_class.call(plan: plan, file: renamed, user: create(:coplan_user)) + + expect(result).to be_reused + expect(result.attachment).to eq(original.attachment) + expect(result.attachment.blob.filename.to_s).to eq("sample.png") + expect(result.attachment.blob.metadata["uploaded_by_id"]).to eq(user.id) + end + + it "stores different contents separately even with the same filename" do + original = described_class.call(plan: plan, file: uploaded_file("sample.txt", "text/plain"), user: user) + changed = uploaded_file("sample.txt", "text/plain") + changed.tempfile.rewind + changed.tempfile.truncate(0) + changed.tempfile.write("x" * original.attachment.blob.byte_size) + changed.tempfile.rewind + result = described_class.call(plan: plan, file: changed, user: user) + + expect(result).to be_success + expect(result).not_to be_reused + expect(result.attachment.blob_id).not_to eq(original.attachment.blob_id) + expect(plan.attachments.count).to eq(2) + end + + it "keeps identical files on different plans independent" do + original = described_class.call(plan: plan, file: uploaded_file, user: user) + other_plan = create(:plan, created_by_user: user) + result = described_class.call(plan: other_plan, file: uploaded_file, user: user) + + expect(result).not_to be_reused + expect(result.attachment.blob_id).not_to eq(original.attachment.blob_id) + expect(other_plan.attachments.count).to eq(1) + end + + it "compares checksums case-sensitively even on a case-insensitive database" do + original = described_class.call(plan: plan, file: uploaded_file, user: user) + original.attachment.blob.update_columns(checksum: original.attachment.blob.checksum.swapcase) + result = described_class.call(plan: plan, file: uploaded_file, user: user) + + expect(result).not_to be_reused + expect(plan.attachments.count).to eq(2) + end + + it "does not save unrelated pending plan changes while attaching" do + plan.title = "An unsaved title" + expect { + result = described_class.call(plan: plan, file: uploaded_file, user: user) + expect(result.error).to include("Save plan changes") + }.not_to change(ActiveStorage::Blob, :count) + expect(plan.reload.title).not_to eq("An unsaved title") + end + it "rejects a missing file" do result = described_class.call(plan: plan, file: nil, user: user) expect(result.error).to include("file is required") diff --git a/spec/system/editor_uploads_spec.rb b/spec/system/editor_uploads_spec.rb new file mode 100644 index 00000000..d5e5b81f --- /dev/null +++ b/spec/system/editor_uploads_spec.rb @@ -0,0 +1,281 @@ +require "rails_helper" +require "base64" + +RSpec.describe "Uploading files in the rich editor", type: :system do + let(:author) { create(:coplan_user, email: "drop-editor@example.com") } + let(:source) { "# Drop uploads\n\nBefore upload.\n\nKeep writing here.\n" } + let(:plan) { CoPlan::Plans::Create.call(title: "Drop uploads", content: source, user: author, visibility: "draft", actor_type: "human") } + + before do + visit sign_in_path + fill_in "Email address", with: author.email + click_button "Sign In" + expect(page).to have_current_path(root_path, wait: 15) + visit plan_edit_page_path(plan) + expect(page).to have_css(".ProseMirror p", text: "Before upload.", wait: 20) + end + + def transfer_files(*names, paste: false) + files = names.map do |name| + path = name == "sample.png" ? Rails.root.join("engine/app/assets/images/coplan/coplan-logo-sm.png") : Rails.root.join("spec/fixtures/files", name) + { name: name, data: Base64.strict_encode64(File.binread(path)), + type: { "sample.png" => "image/png", "sample.txt" => "text/plain", "sample.html" => "text/html" }.fetch(name) } + end + page.execute_script(<<~JS, files, paste) + const transfer = new DataTransfer(); + for (const file of arguments[0]) transfer.items.add(new File([Uint8Array.from(atob(file.data), c => c.charCodeAt(0))], file.name, { type: file.type })); + const editor = document.querySelector('.document-editor__body .ProseMirror'); + const paragraph = editor.querySelector('p') || editor.querySelector('h1'); + paragraph.scrollIntoView({ block: 'center' }); + const rect = paragraph.getBoundingClientRect(); + if (arguments[1]) { + editor.focus(); + const range = document.createRange(); range.selectNodeContents(paragraph); range.collapse(false); + window.getSelection().removeAllRanges(); window.getSelection().addRange(range); + paragraph.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + editor.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true, cancelable: true })); + } else { + paragraph.dispatchEvent(new DragEvent('dragover', { dataTransfer: transfer, bubbles: true, cancelable: true })); + window.dropWasHighlighted = editor.classList.contains('is-file-drop-target'); + paragraph.dispatchEvent(new DragEvent('drop', { dataTransfer: transfer, clientX: rect.left + 4, clientY: rect.top + rect.height / 2, bubbles: true, cancelable: true })); + } + JS + end + + def content + page.evaluate_script('document.querySelector("textarea[name=content]").value') + end + + def hold_upload(network_failure: false) + page.execute_script(<<~JS, network_failure) + const original = window.fetch; + let failNextUpload = arguments[0]; + window.uploadGate = new Promise(resolve => window.releaseUpload = resolve); + window.fetch = async function(url, options) { + if (String(url).endsWith('/attachments') && options?.method === 'POST') { + await window.uploadGate; + if (failNextUpload) { failNextUpload = false; throw new Error('Offline'); } + } + return original.apply(this, arguments); + }; + JS + end + + %w[light dark].product(%w[done navigation], %w[network partial]).each do |theme, exit_path, failure| + it "keeps upload failures visible while leaving through #{exit_path} after #{failure} rejection in #{theme}" do + page.execute_script("document.documentElement.dataset.theme = arguments[0]", theme) + hold_upload(network_failure: failure == "network") + paragraph = find(".ProseMirror p", text: "Keep writing here.") + paragraph.click + paragraph.send_keys(:end, " Draft survives.") + transfer_files(*(failure == "network" ? [ "sample.png" ] : [ "sample.html", "sample.txt" ])) + expect(page).to have_css(".document-editor__upload-placeholder") + location = find('a[aria-label^="Up to "]') + destination = exit_path == "done" ? plan_page_path(plan) : URI.parse(location[:href]).request_uri + exit_editor = -> { exit_path == "done" ? click_link("Done") : find('a[aria-label^="Up to "]').click } + exit_editor.call + expect(page).to have_css(".document-editor__upload-placeholder") + page.execute_script("window.releaseUpload()") + message = failure == "network" ? "Offline" : /sample.html.*not allowed/ + expect(page).to have_css('[role="alert"].document-editor__upload-notice', text: message) + expect(page).to have_css(".document-editor") + expect(page).not_to have_css(".document-editor__upload-placeholder") + expect(content).to include("Draft survives.") + expect(content).not_to include("sample.html") + expect(page).to have_css('.ProseMirror a', text: "sample.txt") if failure == "partial" + + # A later success must not erase an unacknowledged rejection. + transfer_files(failure == "network" ? "sample.txt" : "sample.png", paste: true) + expect(page).to have_css('.ProseMirror a', text: "sample.txt") + expect(page).to have_css('.ProseMirror img[alt="sample.png"]') if failure == "partial" + expect(page).to have_css('[role="alert"].document-editor__upload-notice', text: message) + exit_editor.call + expect(page).to have_css(".document-editor") + page.save_screenshot(Rails.root.join("tmp/screenshots/editor-uploads/failed-#{exit_path}-#{failure}-#{theme}.png")) + + click_button "Dismiss", exact: true + expect(page).not_to have_css(".document-editor__upload-notice") + exit_editor.call + expect(page).not_to have_css(".document-editor", wait: 20) + expect(page).to have_current_path(destination) + expect(plan.reload.current_content).to include("Draft survives.", "[sample.txt]") + expect(plan.current_content).to include("![sample.png]") if failure == "partial" + expect(plan.current_content).not_to include("sample.html") + expect(plan.attachments.count).to eq(failure == "network" ? 1 : 2) + end + end + + def hold_save + page.execute_script(<<~JS) + const original = window.fetch; + let held = false; + window.saveGate = new Promise(resolve => window.releaseSave = resolve); + window.fetch = async function(url, options) { + if (options?.method === 'PATCH' && !held) { held = true; await window.saveGate; } + return original.apply(this, arguments); + }; + JS + end + + %w[light dark].product(%w[done navigation]).each do |theme, exit_path| + it "waits for files added after leaving starts through #{exit_path} in #{theme}" do + page.execute_script("document.documentElement.dataset.theme = arguments[0]", theme) + hold_save + hold_upload + paragraph = find(".ProseMirror p", text: "Before upload.") + paragraph.click + paragraph.send_keys(:end, " Saved before leaving.") + if exit_path == "done" + click_link "Done" + destination = plan_page_path(plan) + else + location = find('a[aria-label^="Up to "]') + destination = URI.parse(location[:href]).request_uri + location.click + end + expect(page).to have_css('.document-editor__close-inline[data-state="saving"]') + transfer_files("sample.png", paste: exit_path == "navigation") + expect(page).to have_css(".document-editor__upload-placeholder") + page.execute_script("window.releaseSave()") + expect(page).not_to have_css('.document-editor__close-inline[data-state="saving"]') + expect(page).to have_css(".document-editor__upload-placeholder") + expect(page).to have_css(".document-editor") + page.save_screenshot(Rails.root.join("tmp/screenshots/editor-uploads/late-#{exit_path}-#{theme}.png")) + + page.execute_script("window.releaseUpload()") + expect(page).not_to have_css(".document-editor", wait: 20) + expect(page).to have_current_path(destination) + expect(plan.reload.current_content).to include("Saved before leaving.", "![sample.png]") + expect(plan.attachments.count).to eq(1) + end + end + + %w[light dark].each do |theme| + it "reuses dropped files while inserting another embed in #{theme}" do + page.execute_script("document.documentElement.dataset.theme = arguments[0]", theme) + transfer_files("sample.png", "sample.txt") + expect(page).to have_css('.ProseMirror img[alt="sample.png"]', count: 1) + original_url = find('.ProseMirror img[alt="sample.png"]')["src"] + transfer_files("sample.png", "sample.txt") + expect(page).to have_css('.ProseMirror img[alt="sample.png"]', count: 2) + expect(page).to have_css('.ProseMirror a', text: "sample.txt", count: 2) + expect(page).to have_css(".document-editor__upload-notice", text: "2 existing files reused.") + expect(all('.ProseMirror img[alt="sample.png"]').map { |image| image["src"] }.uniq).to eq([ original_url ]) + expect(plan.attachments.count).to eq(2) + expect(plan.plan_events.where(event_type: "attachment_added").count).to eq(2) + page.save_screenshot(Rails.root.join("tmp/screenshots/editor-uploads/reused-#{theme}.png")) + find(".document-editor__close-inline:not(:disabled)").click + expect(page).to have_current_path(plan_page_path(plan), wait: 15) + expect(plan.reload.current_content.scan("![sample.png]").size).to eq(2) + expect(page).to have_css('#plan-content-body img[alt="sample.png"]', count: 2) + end + + it "embeds dropped images and files, supports undo, and persists in #{theme}" do + page.execute_script("document.documentElement.dataset.theme = arguments[0]", theme) + hold_upload + transfer_files("sample.png", "sample.txt") + expect(page.evaluate_script("window.dropWasHighlighted")).to be(true) + expect(page).not_to have_css(".ProseMirror.is-file-drop-target") + expect(page).to have_css(".document-editor__upload-placeholder", text: "sample.png, sample.txt") + expect(content).to eq(source) + page.save_screenshot(Rails.root.join("tmp/screenshots/editor-uploads/pending-#{theme}.png")) + page.execute_script("window.releaseUpload()") + expect(page).to have_css('.ProseMirror img[alt="sample.png"]') + expect(page).to have_css('.ProseMirror a[href*="disposition=attachment"]', text: "sample.txt") + expect(page).not_to have_css(".document-editor__upload-placeholder") + click_button "Undo" + expect(page).not_to have_css('.ProseMirror img[alt="sample.png"]') + expect(page).not_to have_css('.ProseMirror a', text: "sample.txt") + click_button "Redo" + expect(page).to have_css('.ProseMirror img[alt="sample.png"]') + page.save_screenshot(Rails.root.join("tmp/screenshots/editor-uploads/embedded-#{theme}.png")) + find(".document-editor__close-inline:not(:disabled)").click + expect(page).to have_current_path(plan_page_path(plan), wait: 15) + expect(page).to have_css('#plan-content-body img[alt="sample.png"]') + expect(plan.reload.current_content).to include("![sample.png]", "[sample.txt]", "Before upload.") + expect(plan.attachments.count).to eq(2) + visit plan_edit_page_path(plan) + expect(page).to have_css('.ProseMirror img[alt="sample.png"]', wait: 20) + end + end + + it "maps the drop point through continued typing and waits for uploads on Done" do + hold_upload + transfer_files("sample.png") + expect(page).to have_css(".document-editor__upload-placeholder") + title = find(".ProseMirror h1") + title.click + title.send_keys(:end, " with edits") + expect(content).to include("# Drop uploads with edits") + paragraph = find(".ProseMirror p", text: "Keep writing here.") + paragraph.click + paragraph.send_keys(:end, " Still writing.") + expect(content).to include("Still writing.") + find(".document-editor__close-inline:not(:disabled)").click + expect(page).to have_css(".document-editor__upload-placeholder") + page.execute_script("window.releaseUpload()") + expect(page).not_to have_css(".document-editor", wait: 20) + expect(plan.reload.current_content).to include("![sample.png]", "Still writing.") + expect(plan.title).to eq("Drop uploads with edits") + expect(page).to have_current_path(plan_page_path(plan)) + expect(plan.current_content.index("![sample.png]")).to be < plan.current_content.index("Keep writing here.") + end + + it "keeps successful files from a mixed drop and lets rejected files be retried" do + transfer_files("sample.html", "sample.txt") + expect(page).to have_css('.ProseMirror a', text: "sample.txt") + expect(page).to have_css('[role="alert"].document-editor__upload-notice', text: /sample.html.*not allowed/) + expect(content).not_to include("sample.html") + expect(page).not_to have_css(".document-editor__upload-placeholder") + transfer_files("sample.png") + expect(page).to have_css('.ProseMirror img[alt="sample.png"]') + end + + it "retains the document on a network failure and accepts another drop" do + page.execute_script(<<~JS) + const original = window.fetch; + window.fetch = function(url, options) { + if (String(url).endsWith('/attachments')) { window.fetch = original; return Promise.reject(new Error('Offline')); } + return original.apply(this, arguments); + }; + JS + transfer_files("sample.png") + expect(page).to have_css('[role="alert"].document-editor__upload-notice', text: "Offline") + expect(page).not_to have_css(".document-editor__upload-placeholder") + expect(content).to eq(source) + transfer_files("sample.png") + expect(page).to have_css('.ProseMirror img[alt="sample.png"]') + end + + it "uploads pasted image files" do + transfer_files("sample.png", paste: true) + expect(page).to have_css('.ProseMirror img[alt="sample.png"]') + expect(plan.attachments.count).to eq(1) + end + + it "synchronizes dropped files into Markdown in Dual mode" do + click_button "Dual", exact: true + transfer_files("sample.png", "sample.txt") + expect(page).to have_css('.ProseMirror img[alt="sample.png"]') + expect(find('[aria-label="Markdown source"]')).to have_text("![sample.png]") + expect(find('[aria-label="Markdown source"]')).to have_text("[sample.txt]") + click_button "Raw", exact: true + expect(find('[aria-label="Markdown source"]')).to have_text("![sample.png]") + click_button "Editor", exact: true + expect(page).to have_css('.ProseMirror img[alt="sample.png"]') + expect(plan.attachments.count).to eq(2) + end + + it "creates an unsaved document before uploading its first file" do + visit new_plan_path + expect(page).to have_css(".ProseMirror", wait: 20) + transfer_files("sample.png") + expect(page).to have_css('.ProseMirror img[alt="sample.png"]', wait: 20) + find(".document-editor__close-inline:not(:disabled)").click + expect(page).not_to have_css(".document-editor") + created = CoPlan::Plan.where(created_by_user: author).order(created_at: :desc).first + expect(created).not_to eq(plan) + expect(created.current_content).to include("![sample.png]") + expect(created.attachments.count).to eq(1) + end +end