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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/DEMO_LIBRARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
6 changes: 6 additions & 0 deletions docs/human-document-editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions engine/app/assets/stylesheets/coplan/document-editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 29 additions & 7 deletions engine/app/controllers/coplan/attachments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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 }),
Expand All @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion engine/app/controllers/coplan/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading