From 91d93a6278e07ea5ae36119ff58f6dead176996d Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 9 Oct 2026 12:28:01 -0400 Subject: [PATCH 1/3] Improve content blocks in the document editor --- ...614_create_coplan_embed_domains.co_plan.rb | 10 + db/schema.rb | 9 +- docs/human-document-editor.md | 27 +- engine/app/admin/embed_domains.rb | 18 + .../assets/stylesheets/coplan/application.css | 37 ++ .../coplan/agent_instructions_controller.rb | 1 + .../coplan/api/v1/embed_domains_controller.rb | 11 + .../coplan/application_controller.rb | 22 ++ .../controllers/coplan/plans_controller.rb | 5 +- engine/app/helpers/coplan/embeds_helper.rb | 34 ++ engine/app/helpers/coplan/markdown_helper.rb | 11 +- .../app/helpers/coplan/references_helper.rb | 12 + .../controllers/coplan/editor_controller.js | 143 +++++++- .../coplan/inline_editor_controller.js | 4 + engine/app/javascript/coplan/rich_document.js | 342 ++++++++++++++++-- engine/app/models/coplan/embed_domain.rb | 19 + .../services/coplan/content_regions/iframe.rb | 61 ++++ .../coplan/references/extract_from_content.rb | 3 + .../agent_instructions/guides/api.text.erb | 4 + .../guides/creating.text.erb | 2 +- .../agent_instructions/guides/embeds.text.erb | 24 ++ .../guides/markdown.text.erb | 4 +- .../guides/presentations.text.erb | 2 + .../coplan/agent_instructions/show.text.erb | 1 + .../views/coplan/plans/_citations.html.erb | 2 +- .../views/coplan/plans/_content_body.html.erb | 2 +- .../views/coplan/plans/_editor_form.html.erb | 21 ++ engine/config/routes.rb | 1 + ...61008000000_create_coplan_embed_domains.rb | 9 + spec/helpers/embeds_helper_spec.rb | 37 ++ spec/models/coplan/embed_domain_spec.rb | 12 + spec/requests/admin_pages_spec.rb | 1 + spec/requests/api/v1/embed_domains_spec.rb | 13 + spec/requests/embed_domains_spec.rb | 28 ++ spec/requests/human_document_editor_spec.rb | 16 + spec/services/content_regions/iframe_spec.rb | 25 ++ .../references/extract_from_content_spec.rb | 8 + spec/system/document_editor_modes_spec.rb | 5 +- spec/system/human_editing_spec.rb | 294 +++++++++++++++ 39 files changed, 1224 insertions(+), 56 deletions(-) create mode 100644 db/migrate/20261008174614_create_coplan_embed_domains.co_plan.rb create mode 100644 engine/app/admin/embed_domains.rb create mode 100644 engine/app/controllers/coplan/api/v1/embed_domains_controller.rb create mode 100644 engine/app/helpers/coplan/embeds_helper.rb create mode 100644 engine/app/models/coplan/embed_domain.rb create mode 100644 engine/app/services/coplan/content_regions/iframe.rb create mode 100644 engine/app/views/coplan/agent_instructions/guides/embeds.text.erb create mode 100644 engine/db/migrate/20261008000000_create_coplan_embed_domains.rb create mode 100644 spec/helpers/embeds_helper_spec.rb create mode 100644 spec/models/coplan/embed_domain_spec.rb create mode 100644 spec/requests/api/v1/embed_domains_spec.rb create mode 100644 spec/requests/embed_domains_spec.rb create mode 100644 spec/services/content_regions/iframe_spec.rb diff --git a/db/migrate/20261008174614_create_coplan_embed_domains.co_plan.rb b/db/migrate/20261008174614_create_coplan_embed_domains.co_plan.rb new file mode 100644 index 00000000..8c36e128 --- /dev/null +++ b/db/migrate/20261008174614_create_coplan_embed_domains.co_plan.rb @@ -0,0 +1,10 @@ +# This migration comes from co_plan (originally 20261008000000) +class CreateCoplanEmbedDomains < ActiveRecord::Migration[8.0] + def change + create_table :coplan_embed_domains, id: :string, limit: 36 do |t| + t.string :hostname, null: false + t.timestamps + end + add_index :coplan_embed_domains, :hostname, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index e7a721bb..7afa7e20 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_10_06_135151) do +ActiveRecord::Schema[8.1].define(version: 2026_10_08_174614) do create_table "active_admin_comments", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "author_id" t.string "author_type" @@ -186,6 +186,13 @@ t.index ["plan_version_id"], name: "fk_rails_14c3f0737b" end + create_table "coplan_embed_domains", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "hostname", null: false + t.datetime "updated_at", null: false + t.index ["hostname"], name: "index_coplan_embed_domains_on_hostname", unique: true + end + create_table "coplan_folders", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.string "created_by_user_id", limit: 36 diff --git a/docs/human-document-editor.md b/docs/human-document-editor.md index 4eb33fb5..306c2484 100644 --- a/docs/human-document-editor.md +++ b/docs/human-document-editor.md @@ -47,8 +47,8 @@ incoming edits map through both editors without focusing the background pane. So select the block's source. The source mode has ordinary text Enter/Tab and Cmd/Ctrl+Z/Shift+Z; rich toolbar controls remain visible but disabled there. -Tables render as table previews with **Edit Markdown** above them. Edit table -cells/rows/alignment in source, then switch back to see the result. There are no +Tables render as previews with **Edit table** above them. Edit table +cells/rows/alignment in the table’s Markdown field to see the result. There are no visual table insertion or cell-editing controls in this prototype. Mermaid fences show editable Mermaid source plus a rendered diagram preview. @@ -57,12 +57,29 @@ the theme. Its expand button opens the existing diagram viewer. **Edit Markdown* selects the whole fence for exact source editing. Invalid/offline previews keep the source available; they do not discard or replace it. -Other unsupported Markdown (task lists, footnotes, raw HTML, reference-style +Footnote citations stay inline in editable paragraphs and lists. Click a citation (or **Edit** beside its reference at the bottom) +to open its definition in a centered dialog. Save stores the definition in Markdown, +with revision checks and the normal autosave pipeline. Definitions stay hidden in the +rich body; Raw retains their exact source. Cancel leaves the definition unchanged. + +Each presentation region is a single block with a large **Presentation Markdown** +field and a slide-screen icon. **Preview** uses the normal slideshow renderer; +**Edit Markdown** returns to the field. Edit slides there; the region's ID and theme remain in its enclosing markers. +Unknown or unclosed regions stay visible source. + +Tables remain separate nodes even inside a list. **Edit table** opens a Markdown +field for only that table, keeping the surrounding list rich and editable. + +**+ → Embed** adds a self-closing iframe block with URL, title, width, +and height fields. Administrators approve exact HTTPS hosts under **Iframe domains**. +No domains are allowed by default. See the served `/agent-instructions/embeds` +guide for syntax, supported sizes, and the fixed sandbox and frame policy. + +Other unsupported Markdown (task lists, raw HTML, reference-style links, strikethrough and mentions) remains source-preserving. Cards show a sanitized reading-style preview when available, with the same source-edit path. Untouched blocks retain their exact spelling. A supported block that you edit in rich mode is reserialized; Markdown mode gives full control over spelling. -Slides remain deferred. ## Code blocks @@ -234,3 +251,5 @@ A reported case where Enter appeared inert inside a code block has not been reproduced in Chrome, the in-app browser, or a disposable copy of the saved content. Passing caret tests do not establish that report's cause or resolution. The original open draft was unavailable for inspection and was left untouched. + +An expired sign-in keeps the draft in the current tab and shows **Sign in again · draft retained**. Sign in in another tab, then retry the original editor. Its uncached editor-state response refreshes the security token before the next save. Citation links use matching labels: `[^catalog]` in the text and `[^catalog]: Source text` in the definitions. Editing the source text in the citation dialog preserves that link; renaming a label in Markdown requires changing both places. diff --git a/engine/app/admin/embed_domains.rb b/engine/app/admin/embed_domains.rb new file mode 100644 index 00000000..cd6dce08 --- /dev/null +++ b/engine/app/admin/embed_domains.rb @@ -0,0 +1,18 @@ +ActiveAdmin.register CoPlan::EmbedDomain, as: "EmbedDomain" do + menu label: "Iframe domains" + permit_params :hostname + + index do + selectable_column + column :hostname + column :created_at + actions + end + + form do |f| + f.inputs "Allowed iframe domain" do + f.input :hostname, hint: "Exact host only, for example www.openstreetmap.org. HTTPS only. Subdomains must be approved separately." + end + f.actions + end +end diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 9612bfff..46ecf4b8 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -8220,3 +8220,40 @@ html[data-theme="light"] .document-editor select, html[data-theme="light"] .docu .thread-popover__permalink:focus-visible .comment-window__copy-feedback, .thread-popover__permalink[data-copied] .comment-window__copy-feedback, .thread-popover__permalink[data-copy-failed] .comment-window__copy-feedback { opacity: 1; } + + +.document-editor__footnote { color: var(--color-primary); font-size: 0.7em; white-space: nowrap; } +.document-editor__footnote button { border: 0; background: transparent; color: inherit; padding: 0 2px; font: inherit; cursor: pointer; border-radius: 3px; } +.document-editor__footnote button:hover { background: var(--color-interaction-hover-bg); } +.document-editor__footnote button:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; } +.document-editor__block-source { display: block; width: 100%; min-height: 320px; resize: vertical; border: 0; padding: 18px; background: var(--color-surface); color: var(--color-text); font: 14px/1.7 var(--font-mono, monospace); box-sizing: border-box; } +.document-editor__block-source:focus { outline: 2px solid var(--color-primary); outline-offset: -2px; } +.document-editor__embed-fields { padding: 16px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; font: 13px/1.5 var(--font-sans, sans-serif); } +.document-editor__embed-fields label { display: flex; flex-direction: column; gap: 4px; } +.document-editor__embed-fields label:first-child { grid-column: 1 / -1; } +.document-editor__embed-fields input, .document-editor__citation-dialog textarea { width: 100%; box-sizing: border-box; border: 1px solid var(--color-border); border-radius: 6px; padding: 9px 10px; background: var(--color-surface); color: var(--color-text); font: inherit; } +.document-editor__citation-dialog { position: fixed; inset: 0; margin: auto; max-height: calc(100dvh - 40px); overflow: auto; width: min(560px, calc(100vw - 40px)); border: 1px solid var(--color-border); border-radius: 16px; padding: 24px; background: var(--color-surface); color: var(--color-text); box-shadow: 0 16px 60px #0004; } +.document-editor__citation-dialog::backdrop { background: #0005; } +.document-editor__citation-heading, .document-editor__citation-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.document-editor__citation-heading h2 { margin: 0 0 16px; font-size: 18px; } +.document-editor__citation-heading button { border: 0; background: transparent; color: var(--color-text-muted); font-size: 24px; cursor: pointer; } +.document-editor__citation-dialog label { display: block; margin-bottom: 8px; font-size: 13px; } +.document-editor__citation-dialog textarea { font: 14px/1.6 var(--font-mono, monospace); resize: vertical; } +.document-editor__citation-actions { justify-content: flex-end; } +.document-editor__citation-error { color: var(--color-danger); min-height: 1.5em; font-size: 13px; } +.iframe-content-block { margin: 20px 0; max-width: 100%; } +.iframe-content-block iframe { display: block; max-width: 100%; border: 1px solid var(--color-border); border-radius: 8px; } +.iframe-content-block__unavailable { padding: 18px; border: 1px solid var(--color-border); border-radius: 8px; color: var(--color-text-muted); } + +.document-editor__embed-hint { margin: 0; padding: 0 16px 10px; font: 12px/1.5 var(--font-sans, sans-serif); color: var(--color-text-muted); } + +.citation-edit-control { display: none; } +[data-editing="true"] .citation-edit-control { display: inline-block; border: 0; background: transparent; color: var(--color-primary); cursor: pointer; padding: 4px 8px; border-radius: 4px; } +[data-editing="true"] .citation-edit-control:hover { background: var(--color-interaction-hover-bg); } + +.document-editor__presentation-label { display: inline-flex; align-items: center; gap: 0.5rem; } +.document-editor__presentation-label svg { width: 1.25rem; height: 1.25rem; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; } + +.document-editor__content-picker { width: 200px; } +.document-editor__content-picker > button { display: flex; align-items: center; justify-content: flex-start; gap: 10px; width: 100%; height: auto; padding: 10px; text-align: left; font-size: 14px; border-radius: 6px; color: var(--color-text); } +.document-editor__content-picker > button:hover, .document-editor__content-picker > button:focus-visible { background: var(--color-primary-light); color: var(--color-primary); } diff --git a/engine/app/controllers/coplan/agent_instructions_controller.rb b/engine/app/controllers/coplan/agent_instructions_controller.rb index 8f98fc16..0e180ca7 100644 --- a/engine/app/controllers/coplan/agent_instructions_controller.rb +++ b/engine/app/controllers/coplan/agent_instructions_controller.rb @@ -32,6 +32,7 @@ class AgentInstructionsController < ApplicationController GUIDES = { "markdown" => "markdown", "presentations" => "presentations", + "embeds" => "embeds", "creating" => "creating", "editing" => "editing", "comments" => "comments", diff --git a/engine/app/controllers/coplan/api/v1/embed_domains_controller.rb b/engine/app/controllers/coplan/api/v1/embed_domains_controller.rb new file mode 100644 index 00000000..53300a68 --- /dev/null +++ b/engine/app/controllers/coplan/api/v1/embed_domains_controller.rb @@ -0,0 +1,11 @@ +module CoPlan + module Api + module V1 + class EmbedDomainsController < BaseController + def index + render json: { hostnames: EmbedDomain.order(:hostname).pluck(:hostname) } + end + end + end + end +end diff --git a/engine/app/controllers/coplan/application_controller.rb b/engine/app/controllers/coplan/application_controller.rb index 07ee12c3..68ca7062 100644 --- a/engine/app/controllers/coplan/application_controller.rb +++ b/engine/app/controllers/coplan/application_controller.rb @@ -33,6 +33,7 @@ def self.controller_path before_action :set_coplan_current after_action :set_agent_instructions_header after_action :track_page_view + after_action :restrict_embedded_frames helper_method :current_user, :signed_in?, :show_api_tokens? @@ -44,6 +45,27 @@ class NotAuthorizedError < StandardError; end private + # A second CSP composes with any stricter host policy. It also constrains + # frame redirects, rather than trusting only the initial iframe URL. + def restrict_embedded_frames + return unless response.media_type == "text/html" + + hosts = EmbedDomain.order(:hostname).pluck(:hostname).reject { |host| host == request.host.downcase } + policy = "frame-src #{hosts.empty? ? "'none'" : hosts.map { |host| "https://#{host}" }.join(' ')}" + existing = response.headers["Content-Security-Policy"] + # Rails' middleware skips generating its policy when a response already + # has a CSP header. Build the host policy first so we do not replace it. + if (host_policy = request.content_security_policy) + configured = host_policy.build(self, request.content_security_policy_nonce, request.content_security_policy_nonce_directives) + if request.content_security_policy_report_only + response.headers["Content-Security-Policy-Report-Only"] ||= configured + else + existing = [ existing.presence, configured.presence ].compact.join(", ") + end + end + response.headers["Content-Security-Policy"] = [ existing.presence, policy ].compact.join(", ") + end + def current_user @current_coplan_user end diff --git a/engine/app/controllers/coplan/plans_controller.rb b/engine/app/controllers/coplan/plans_controller.rb index b1eddea8..2d41aa88 100644 --- a/engine/app/controllers/coplan/plans_controller.rb +++ b/engine/app/controllers/coplan/plans_controller.rb @@ -249,6 +249,7 @@ def create def editor_state authorize!(@plan, :edit_content?) response.headers["Cache-Control"] = "no-store" + response.headers["X-CSRF-Token"] = form_authenticity_token render json: editor_snapshot end @@ -263,7 +264,7 @@ def editor_lease end def preview_draft - render html: helpers.render_markdown(params[:content].to_s, interactive: false), layout: false + render html: helpers.render_content_regions(params[:content].to_s, interactive: false), layout: false end # Human whole-document editing goes through the same pipeline as agent @@ -311,7 +312,7 @@ def update_content # render: no checkbox wiring, since the content isn't saved yet. def preview authorize!(@plan, :show?) - html = helpers.render_markdown(params[:content].to_s, interactive: false) + html = helpers.render_content_regions(params[:content].to_s, interactive: false) render html: html, layout: false end diff --git a/engine/app/helpers/coplan/embeds_helper.rb b/engine/app/helpers/coplan/embeds_helper.rb new file mode 100644 index 00000000..00da0a13 --- /dev/null +++ b/engine/app/helpers/coplan/embeds_helper.rb @@ -0,0 +1,34 @@ +module CoPlan + module EmbedsHelper + def render_iframe_blocks(html, source) + return html unless source.include?(ContentRegions::Iframe::PREFIX) + + lines = source.lines + doc = Nokogiri::HTML::DocumentFragment.parse(html) + # Only root-level, standalone paragraphs are content blocks. A marker + # written in inline/fenced code, a quotation or a list stays literal. + doc.children.select { |node| node.name == "p" }.each do |paragraph| + pos = /\A(\d+):\d+-\1:\d+\z/.match(paragraph["data-sourcepos"].to_s) + next unless pos + + block = ContentRegions::Iframe.call(lines[pos[1].to_i - 1].to_s.chomp) + next unless block + + markup = if block.allowed?(host: request&.host) + attrs = block.attributes + width = attrs["width"].end_with?("%") ? attrs["width"] : "#{attrs['width']}px" + tag.div(class: "iframe-content-block") do + tag.iframe("", src: attrs["src"], title: attrs["title"], height: attrs["height"], + style: "width: #{width}", sandbox: "allow-scripts allow-forms allow-same-origin", loading: "lazy", + referrerpolicy: "strict-origin-when-cross-origin", allow: "camera 'none'; microphone 'none'; geolocation 'none'") + end + else + tag.div("Embedded page unavailable. Check its URL, size, and administrator-approved domain.", + class: "iframe-content-block__unavailable", role: "status") + end + paragraph.replace(Nokogiri::HTML::DocumentFragment.parse(markup)) + end + doc.to_html + end + end +end diff --git a/engine/app/helpers/coplan/markdown_helper.rb b/engine/app/helpers/coplan/markdown_helper.rb index ee5c93dc..f95a784f 100644 --- a/engine/app/helpers/coplan/markdown_helper.rb +++ b/engine/app/helpers/coplan/markdown_helper.rb @@ -1,5 +1,6 @@ module CoPlan module MarkdownHelper + include EmbedsHelper ALLOWED_TAGS = %w[ h1 h2 h3 h4 h5 h6 p div span @@ -34,7 +35,7 @@ module MarkdownHelper # version. Bump it whenever the rendering pipeline changes output for the # same input (new tags, attribute changes, checkbox wiring, etc.), or # stale HTML will be served from cache. - RENDER_CACHE_VERSION = 19 + RENDER_CACHE_VERSION = 20 # Matches `[@username](mention:username)` where the bracket text and link # target encode the same username. Username allows letters, digits, dots, @@ -64,12 +65,18 @@ def render_markdown(content, interactive: true, footnote_prefix: nil, footnotes: render_options = { unsafe: true } # Sourcepos wires checkboxes and structural comment targets to source; # it is stripped after generating trusted interaction metadata. - render_options[:sourcepos] = true if interactive + render_options[:sourcepos] = true if interactive || content.to_s.include?(ContentRegions::Iframe::PREFIX) html = Commonmarker.to_html(content.to_s.encode("UTF-8"), options: { extension: EXTENSION_OPTIONS, render: render_options }, plugins: { syntax_highlighter: nil }) with_chips = transform_mention_anchors(html) with_references = transform_reference_anchors(with_chips, numbered_sections: footnote_prefix.nil?) sanitized = sanitize(with_references, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES) + sanitized = render_iframe_blocks(sanitized, content.to_s) result = interactive ? make_checkboxes_interactive(sanitized, content, line_offset: line_offset, source_comments: source_comments, retain_sourcepos: retain_sourcepos) : sanitized + unless interactive + fragment = Nokogiri::HTML::DocumentFragment.parse(result) + fragment.css("[data-sourcepos]").each { |node| node.remove_attribute("data-sourcepos") } + result = fragment.to_html + end result = scope_footnote_ids(result, footnote_prefix) if footnote_prefix result = select_footnotes(result, footnotes) return result.html_safe if footnotes == :only diff --git a/engine/app/helpers/coplan/references_helper.rb b/engine/app/helpers/coplan/references_helper.rb index b32c5722..e325bcea 100644 --- a/engine/app/helpers/coplan/references_helper.rb +++ b/engine/app/helpers/coplan/references_helper.rb @@ -58,6 +58,18 @@ def plan_citation_back_matter(plan, references) } end + def citation_edit_controls(html) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + doc.css("section[data-footnotes] > ol > li[id]").each do |entry| + label = entry["id"].delete_prefix("fn-") + button = tag.button("Edit", type: "button", class: "citation-edit-control", + aria: { label: "Edit reference #{label}" }, + data: { citation_label: label, action: "coplan--inline-editor#editCitation" }) + entry.add_child(Nokogiri::HTML::DocumentFragment.parse(button)) + end + doc.to_html.html_safe + end + def listed_plan_references(references, cited_urls) references.reject { |reference| cited_urls.include?(reference.url) } end diff --git a/engine/app/javascript/controllers/coplan/editor_controller.js b/engine/app/javascript/controllers/coplan/editor_controller.js index 756b1cae..a4051d33 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", "codeOption", "draftNotice", "legacyDraftNotice", "conflict", "error", "retry", "review", "replace", "latest", "style", "subscription"] - static values = { planId: String, userId: String, draftScope: String, revision: Number, stateUrl: String, previewUrl: String, leaseUrl: String, inline: Boolean } + 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, previewUrl: String, leaseUrl: String, inline: Boolean, embedDomains: Array } async connect() { this.active = true @@ -119,7 +119,13 @@ export default class extends Controller { const response = await fetch(url, { method, signal: abort.signal, cache: "no-store", headers: { "Content-Type": "application/json", Accept: "application/json", "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }) - if (!response.headers.get("content-type")?.includes("application/json")) throw new Error("Could not sync. Check your connection or sign in again; your draft is retained.") + if (response.redirected || response.status === 401) throw Object.assign(new Error("Sign in again in another tab, then retry here. Your draft is retained."), { code: "sign_in_required" }) + if (!response.headers.get("content-type")?.includes("application/json")) { + if (response.status === 422) throw Object.assign(new Error("Your session changed. Sign in again in another tab, then retry here. Your draft is retained."), { code: "sign_in_required" }) + throw new Error("Could not sync. Check your connection or sign in again; your draft is retained.") + } + const token = response.headers.get("X-CSRF-Token") + if (response.ok && token) document.querySelector('meta[name="csrf-token"]')?.setAttribute("content", token) const data = await response.json() if (!response.ok) throw Object.assign(new Error(data.error || "Could not save"), data, { status: response.status }) return data @@ -219,7 +225,7 @@ export default class extends Controller { if (this.isNew && error.status === 422) { this.creationSnapshot = null; this.persistDraft() } if (error.code === "overlapping_edits" || error instanceof this.merge.MergeConflict) this.showConflict(error.code === "overlapping_edits" ? error : this.pendingRemote, error.message) else { - this.fail(error.message || "Offline — your draft is retained") + this.fail(error.message || "Offline — your draft is retained", error.code) } return false } finally { @@ -285,7 +291,7 @@ export default class extends Controller { return true } catch (error) { if (error instanceof this.merge.MergeConflict) this.showConflict(this.pendingRemote, error.message) - else if (this.dirty()) this.fail("Offline or disconnected · draft retained. We’ll retry when connected.") + else if (this.dirty()) this.fail(error.code === "sign_in_required" ? error.message : "Offline or disconnected · draft retained. We’ll retry when connected.", error.code) else if (this.inlineValue && this.statusTarget.dataset.state === "saving") this.fail("Could not check the saved version") return false } finally { @@ -362,10 +368,10 @@ export default class extends Controller { if (heading) heading.textContent = this.base.title } } - fail(message) { + fail(message, code = null) { if (!this.active) return this.retryable = !this.blocked - const status = this.inlineValue ? (this.blocked ? "Conflict — reload document to start over" : "Couldn't save · click to retry") : "Not saved" + const status = code === "sign_in_required" ? "Sign in again · draft retained" : this.inlineValue ? (this.blocked ? "Conflict — reload document to start over" : "Couldn't save · click to retry") : "Not saved" this.setStatus(status, this.blocked && this.inlineValue ? "conflict" : "error") this.errorTarget.textContent = this.blocked ? message : `Couldn't save. Your edits are still here. ${message}` this.conflictTarget.dataset.kind = this.blocked ? "conflict" : "save-error" @@ -383,8 +389,10 @@ export default class extends Controller { } keepSelection(event) { if (event.target.closest("button")) event.preventDefault() } updateToolbar(state) { - const rawFocused = this.editor === this.rawEditor && !!this.rawEditor + const rawFocused = (this.editor === this.rawEditor && !!this.rawEditor) || !!document.activeElement?.closest(".document-editor__content-block, .document-editor__block-source") this.toolbarTarget.querySelector('[popovertarget="coplan-insert-code"]').disabled = rawFocused + this.toolbarTarget.querySelector('[popovertarget="coplan-insert-content"]').disabled = rawFocused + if (rawFocused && this.contentPickerTarget.matches(":popover-open")) this.contentPickerTarget.hidePopover() if (rawFocused && this.codePickerTarget.matches(":popover-open")) this.codePickerTarget.hidePopover() this.toolbarTarget.querySelectorAll("[data-command]").forEach(button => { const command = button.dataset.command @@ -635,6 +643,125 @@ export default class extends Controller { const position = event.currentTarget.closest(".document-editor__block").coplanPosition() this.richEditor.deleteCode(position) } + blockSourceChanged(event) { + const block = event.currentTarget.closest(".document-editor__block") + const position = block.coplanPosition(), node = this.richEditor.view.state.doc.nodeAt(position) + const input = event.currentTarget, value = input.value, start = input.selectionStart, end = input.selectionEnd + const source = node.attrs.kind === "presentation" ? (() => { + const parts = this.richModule.presentationContent(node.attrs.source) + const content = event.currentTarget.value + return parts.before + content + (content.endsWith("\n") ? "" : "\n") + parts.after + })() : event.currentTarget.value + this.richEditor.setBlockSource(position, source) + // Structural separator lines must not alter the field while it is being + // typed into (including native select-all replacement). + if (node.attrs.kind === "presentation" && input.value !== value) { + input.value = value; input.setSelectionRange(start, end) + } + } + togglePresentationPreview(event) { + event.currentTarget.closest(".document-editor__block").coplanTogglePreview() + } + toggleBlockSource(event) { + const block = event.currentTarget.closest(".document-editor__block") + const input = block.querySelector("textarea") + input.hidden = !input.hidden + event.currentTarget.textContent = input.hidden ? "Edit table" : "Done" + if (!input.hidden) input.focus({ preventScroll: true }) + } + blockKeydown(event) { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") { + event.preventDefault(); event.stopPropagation() + this.richEditor.command(event.shiftKey ? "redo" : "undo") + event.currentTarget.focus({ preventScroll: true }) + } + } + embedChanged(event) { + const block = event.currentTarget.closest(".document-editor__block") + const attrs = Object.fromEntries(Array.from(block.querySelectorAll("[data-embed-attribute]"), input => [input.dataset.embedAttribute, input.value])) + this.richEditor.setBlockSource(block.coplanPosition(), this.richModule.iframeSource(attrs)) + } + deleteContentBlock(event) { + const position = event.currentTarget.closest(".document-editor__block").coplanPosition() + this.richEditor.deleteContentBlock(position) + } + prepareContentPicker(event) { + if (event.newState !== "open") return + this.richEditor.captureSelection() + this.positionContentPicker(true) + } + contentPickerToggled(event) { + const open = event.newState === "open" + this.element.querySelector('[popovertarget="coplan-insert-content"]').setAttribute("aria-expanded", String(open)) + if (open) { + this.positionContentPicker(true) + this.contentPickerTarget.querySelector("button").focus({ preventScroll: true }) + } + } + positionContentPicker(opening = false) { + if (opening !== true && !this.contentPickerTarget.matches(":popover-open")) return + const rect = this.element.querySelector('[popovertarget="coplan-insert-content"]').getBoundingClientRect() + const picker = this.contentPickerTarget, width = picker.offsetWidth || 200, height = picker.offsetHeight || 104 + picker.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))}px` + picker.style.top = `${window.innerHeight - rect.bottom < height + 16 ? Math.max(8, rect.top - height - 6) : rect.bottom + 6}px` + } + insertPresentation() { + this.insertContentBlock("::: {.presentation}\n\n# Slide title\n\nSlide content.\n\n:::\n", '[aria-label="Presentation Markdown"]') + } + insertEmbed() { + const source = this.richModule.iframeSource({ src: "https://", title: "Embedded content", width: "100%", height: "480" }) + this.insertContentBlock(source, '[data-embed-attribute="src"]') + } + insertContentBlock(source, focusSelector) { + if (this.editor !== this.richEditor) return + this.contentPickerTarget.hidePopover() + const view = this.richEditor.view + const node = this.richModule.parseDocument(source).firstChild + // An atomic content block has no text caret. Add after a selected block + // rather than replacing the existing presentation or embed. + const transaction = (view.state.selection.node ? view.state.tr.insert(view.state.selection.to, node) : view.state.tr.replaceSelectionWith(node)).scrollIntoView() + let position + transaction.doc.descendants((candidate, pos) => { if (candidate === node) position = pos }) + view.dispatch(transaction) + view.nodeDOM(position)?.querySelector(focusSelector)?.focus({ preventScroll: true }) + } + openCitation(event) { + event.preventDefault(); event.stopPropagation() + this.citationTrigger = event.currentTarget + this.citationLabel = event.currentTarget.dataset.citationLabel + const definition = this.richModule.citationDefinitions(this.textareaTarget.value).find(d => d.label === this.citationLabel) + this.citationOriginal = definition?.source || null + this.citationTitleTarget.textContent = `Citation: ${this.citationLabel}` + this.citationBodyTarget.value = definition?.body || "" + this.citationErrorTarget.textContent = "" + this.citationDialogTarget.showModal() + this.citationBodyTarget.focus({ preventScroll: true }) + } + closeCitation() { + this.citationDialogTarget.close() + if (this.citationTrigger?.isConnected) this.citationTrigger.focus({ preventScroll: true }) + } + async saveCitation() { + const body = this.citationBodyTarget.value.trim() + if (!body) { this.citationErrorTarget.textContent = "Add the citation text or source link."; this.citationBodyTarget.focus(); return } + const content = this.textareaTarget.value + const definition = this.richModule.citationDefinitions(content).find(d => d.label === this.citationLabel) + if ((definition?.source || null) !== this.citationOriginal) { + this.citationErrorTarget.textContent = "This citation changed while you were editing. Close and reopen it to review the new text." + return + } + const lines = body.split("\n") + const source = `[^${this.citationLabel}]: ${lines[0]}\n` + lines.slice(1).map(line => ` ${line}\n`).join("") + const from = definition?.from ?? content.length, to = definition?.to ?? content.length + this.richEditor.replaceSource(from, to, definition ? source : `${content.endsWith("\n\n") ? "" : "\n\n"}${source}`) + this.citationOriginal = source + this.citationSaveTarget.disabled = true + this.citationSaveTarget.textContent = "Saving…" + try { + if (await this.flush(true)) this.closeCitation() + else this.citationErrorTarget.textContent = "The citation is in your draft. Retry Save to sync it." + } finally { this.citationSaveTarget.disabled = false; this.citationSaveTarget.textContent = "Save citation" } + } editSource(event) { const position = event.currentTarget.closest(".document-editor__block").coplanPosition() const range = this.richEditor.sourceRange(position) diff --git a/engine/app/javascript/controllers/coplan/inline_editor_controller.js b/engine/app/javascript/controllers/coplan/inline_editor_controller.js index 88dea45a..bf71b1a8 100644 --- a/engine/app/javascript/controllers/coplan/inline_editor_controller.js +++ b/engine/app/javascript/controllers/coplan/inline_editor_controller.js @@ -82,6 +82,10 @@ export default class extends Controller { if (!this.headerVisible) restoreViewport(this.editorTarget, this.anchor) } + editCitation(event) { + if (this.editing) this.controller?.openCitation(event) + } + async closed(event) { if (!this.editing || this.closing || event.detail.controller !== this.controller) return if (this.draftValue) { diff --git a/engine/app/javascript/coplan/rich_document.js b/engine/app/javascript/coplan/rich_document.js index 15d3b8d9..ccf0b7e8 100644 --- a/engine/app/javascript/coplan/rich_document.js +++ b/engine/app/javascript/coplan/rich_document.js @@ -4,7 +4,7 @@ import { textHunks } from "coplan/merge_text" import { Schema, Fragment, Slice } from "prosemirror-model" import { EditorState, TextSelection, Plugin, PluginKey } from "prosemirror-state" import { EditorView, Decoration, DecorationSet } from "prosemirror-view" -import { defaultMarkdownParser, defaultMarkdownSerializer } from "prosemirror-markdown" +import { MarkdownParser, MarkdownSerializer, defaultMarkdownParser, defaultMarkdownSerializer as baseMarkdownSerializer } from "prosemirror-markdown" import { baseKeymap, toggleMark, setBlockType, wrapIn, lift, chainCommands, exitCode, selectAll } from "prosemirror-commands" import { wrapInList, splitListItem, liftListItem, sinkListItem } from "prosemirror-schema-list" import { keymap } from "prosemirror-keymap" @@ -21,11 +21,120 @@ nodes.forEach((name, spec) => { if (name !== "text") nodes = nodes.update(name, { ...spec, attrs: { ...spec.attrs, source: { default: null }, snapshot: { default: null } } }) }) nodes = nodes.addBefore("paragraph", "preserved", { - group: "block", atom: true, attrs: { source: {} }, + group: "block", atom: true, attrs: { source: {}, kind: { default: null }, label: { default: null } }, toDOM: node => ["div", { class: "document-editor__preserved", hidden: node.attrs.source.trim() ? null : "hidden", contenteditable: "false" }, ["small", "Preserved Markdown block"], ["pre", node.attrs.source]] }) +nodes = nodes.addBefore("text", "footnote_reference", { + inline: true, group: "inline", atom: true, attrs: { label: {} }, + leafText: node => `[^${node.attrs.label}]`, + parseDOM: [{ tag: "sup[data-coplan-footnote]", getAttrs: dom => ({ label: dom.dataset.coplanFootnote }) }], + toDOM: node => ["sup", { "data-coplan-footnote": node.attrs.label, class: "document-editor__footnote", title: `Reference: ${node.attrs.label}`, contenteditable: "false" }, ["button", { type: "button", "data-citation-label": node.attrs.label, "data-action": "coplan--editor#openCitation", "aria-label": `Edit citation ${node.attrs.label}` }, `[${node.attrs.label}]`]] +}) const schema = new Schema({ nodes, marks: defaultMarkdownParser.schema.spec.marks }) +// Run before links, but after escapes and code spans. A citation is inline +// content, so it must not turn its entire paragraph or list into a source card. +const tokenizer = defaultMarkdownParser.tokenizer +tokenizer.inline.ruler.before("link", "coplan_footnote", (state, silent) => { + const match = /^\[\^([^\]\s]+)\]/.exec(state.src.slice(state.pos)) + if (!match) return false + if (!silent) state.push("footnote_reference", "", 0).meta = { label: match[1] } + state.pos += match[0].length + return true +}) +// Keep a table's own node inside its surrounding list or blockquote. The +// list remains rich text; only the table uses a source-preserving preview. +tokenizer.enable("table") +tokenizer.core.ruler.after("block", "coplan_tables", state => { + const lines = state.src.split("\n") + for (let index = 0; index < state.tokens.length; index++) { + const token = state.tokens[index] + if (token.type !== "table_open") continue + const end = state.tokens.findIndex((next, at) => at > index && next.type === "table_close") + if (end < 0) continue + const quoteDepth = state.tokens.slice(0, index).reduce((depth, current) => depth + + (current.type === "blockquote_open" ? 1 : current.type === "blockquote_close" ? -1 : 0), 0) + const source = lines.slice(...token.map).map(line => { + for (let depth = 0; depth < quoteDepth; depth++) line = line.replace(/^ {0,3}> ?/, "") + return line + }).join("\n") + const indentation = source.match(/^[ \t]*/)[0] + const normalized = source.split("\n").map(line => line.startsWith(indentation) ? line.slice(indentation.length) : line).join("\n") + "\n" + token.type = "preserved_block"; token.nesting = 0 + token.meta = { source: normalized, kind: "table" } + state.tokens.splice(index + 1, end - index) + } +}) +const richMarkdownParser = new MarkdownParser(schema, tokenizer, { + ...defaultMarkdownParser.tokens, + footnote_reference: { node: "footnote_reference", getAttrs: token => token.meta }, + preserved_block: { node: "preserved", getAttrs: token => token.meta } +}) +const defaultMarkdownSerializer = new MarkdownSerializer({ + ...baseMarkdownSerializer.nodes, + footnote_reference: (state, node) => state.text(`[^${node.attrs.label}]`, false), + preserved: (state, node) => { + for (const line of node.attrs.source.trimEnd().split("\n")) { state.write(line); state.ensureNewLine() } + state.closeBlock(node) + } +}, baseMarkdownSerializer.marks) + +const presentationOpen = /^::: \{\.presentation(?: #([a-zA-Z][\w-]*))?(?: theme="(coplan|graphite)")?\}$/ +function contentRanges(tokens, lines, offsets, markdown, definitionsOnly = false) { + const ranges = tokens.filter(t => t.level === 0 && t.map && t.nesting !== -1) + const specials = [] + let opener = null + for (const token of ranges) { + const [from, to] = token.map + if (token.type !== "paragraph_open" || to !== from + 1) continue + const line = lines[from].replace(/\r$/, "") + if (opener !== null) { + if (line !== ":::") continue + specials.push({ from: offsets[opener], to: Math.min(offsets[to], markdown.length), kind: "presentation" }) + opener = null + } else if (presentationOpen.test(line)) opener = from + if (line.startsWith("::: {.iframe ") && line.endsWith(" /}")) + specials.push({ from: offsets[from], to: Math.min(offsets[to], markdown.length), kind: "iframe" }) + } + // Definitions are metadata, not visible body blocks. Keep their exact source, + // including indented continuation paragraphs, for citation editing and Raw. + const excluded = ranges.filter(t => ["fence", "code_block", "html_block", "blockquote_open", "bullet_list_open", "ordered_list_open"].includes(t.type)) + for (let line = 0; line < lines.length; line++) { + if (excluded.some(t => line >= t.map[0] && line < t.map[1])) continue + const match = /^ {0,3}\[\^([^\]\s]+)\]:[ \t]*(.*)$/.exec(lines[line]) + if (!match || (!definitionsOnly && specials.some(r => offsets[line] >= r.from && offsets[line] < r.to))) continue + let end = line + 1 + while (end < lines.length) { + if (/^(?: {4}|\t)\S?/.test(lines[end]) && lines[end].trim()) { end++; continue } + if (!lines[end].trim() && /^(?: {4}|\t)[ \t]*\S/.test(lines[end + 1] || "")) { end += 2; continue } + break + } + specials.push({ from: offsets[line], to: Math.min(offsets[end], markdown.length), kind: "reference", label: match[1] }) + line = end - 1 + } + return specials.filter(range => !definitionsOnly || range.kind === "reference").sort((a, b) => a.from - b.from) +} +export function presentationContent(source) { + let first = source.indexOf("\n") + 1 + // Blank separator lines belong to the wrapper, so replacing all of the + // field cannot merge the closing marker into the last slide paragraph. + if (source[first] === "\n") first++ + else if (source.slice(first, first + 2) === "\r\n") first += 2 + let last = source.lastIndexOf(":::") + if (source.slice(last - 2, last) === "\n\n") last-- + else if (source.slice(last - 4, last) === "\r\n\r\n") last -= 2 + return { before: source.slice(0, first), content: source.slice(first, last), after: source.slice(last) } +} +export function citationDefinitions(source) { + const lines = source.split("\n"), offsets = [0] + lines.forEach(line => offsets.push(offsets.at(-1) + line.length + 1)) + return contentRanges(tokenizer.parse(source, {}), lines, offsets, source, true).map(range => { + const text = source.slice(range.from, range.to) + const match = /^ {0,3}\[\^[^\]]+\]:[ \t]*(.*?)(?:\r?\n|$)/.exec(text) + const body = (match?.[1] || "") + "\n" + text.slice(match?.[0].length || 0).replace(/^ {4}|^\t/gm, "") + return { label: range.label, body: body.trimEnd(), source: text, from: range.from, to: range.to } + }) +} function signature(node) { const json = node.toJSON() if (json.attrs) json.attrs = Object.fromEntries(Object.entries(json.attrs).filter(([key]) => !["source", "snapshot"].includes(key))) @@ -63,34 +172,49 @@ function moveBelowCode(state, dispatch) { } return true } -function preserved(source) { return schema.nodes.preserved.create({ source }) } +function preserved(source, kind = null, label = null) { return schema.nodes.preserved.create({ source, kind, label }) } export function parseDocument(markdown) { if (!markdown) return schema.topNodeType.createAndFill() const lines = markdown.split("\n") const offsets = [0] lines.forEach(line => offsets.push(offsets.at(-1) + line.length + 1)) const tokens = defaultMarkdownParser.tokenizer.parse(markdown, {}) - const ranges = tokens.filter(t => t.level === 0 && t.map && t.nesting !== -1).map(t => t.map) + const specials = contentRanges(tokens, lines, offsets, markdown) const blocks = [] + const segments = [] let cursor = 0 - for (const [from, to] of ranges) { - const start = offsets[from], end = Math.min(offsets[to], markdown.length) - if (start < cursor) continue - if (start > cursor) blocks.push(preserved(markdown.slice(cursor, start))) - const source = markdown.slice(start, end) - try { - // Raw HTML, tables, task lists, footnotes and reference definitions are - // preserved until dedicated rich node views exist for them. - const parsed = defaultMarkdownParser.parse(source) - if (parsed.firstChild?.type.name !== "code_block" && /~~|\]\s*\[|\]\(mention:|<\/?[a-z!]|^\s*\|.*\||^\s*\|?\s*:?-{3,}.*\||^\s*(?:>\s*)*(?:[-*+]|\d+[.)]) \[[ xX]\]|^\s*\[[^\]]+\]:|\[\^[^\]]+\]|^:::(?: \{\.presentation.*\})?$/im.test(source)) throw new Error("preserve") - if (parsed.childCount !== 1) throw new Error("preserve") - let node = schema.nodeFromJSON(parsed.firstChild.toJSON()) - node = node.type.create({ ...node.attrs, source, snapshot: signature(node) }, node.content, node.marks) - blocks.push(node) - } catch { blocks.push(preserved(source)) } - cursor = end + for (const special of specials) { + if (special.from < cursor) continue + if (special.from > cursor) segments.push({ source: markdown.slice(cursor, special.from) }) + segments.push({ ...special, source: markdown.slice(special.from, special.to) }) + cursor = special.to + } + if (cursor < markdown.length) segments.push({ source: markdown.slice(cursor) }) + for (const segment of segments) { + if (segment.kind) { blocks.push(preserved(segment.source, segment.kind, segment.label)); continue } + const source = segment.source + const segmentLines = source.split("\n"), segmentOffsets = [0] + segmentLines.forEach(line => segmentOffsets.push(segmentOffsets.at(-1) + line.length + 1)) + const ranges = tokenizer.parse(source, {}).filter(t => t.level === 0 && t.map && t.nesting !== -1) + let consumed = 0 + for (const token of ranges) { + const [from, to] = token.map + const start = segmentOffsets[from], end = Math.min(segmentOffsets[to], source.length) + if (start < consumed) continue + if (start > consumed) blocks.push(preserved(source.slice(consumed, start))) + const text = source.slice(start, end) + try { + const parsed = richMarkdownParser.parse(text) + if (parsed.firstChild?.type.name !== "code_block" && /~~|\]\s*\[|\]\(mention:|<\/?[a-z!]|^\s*(?:>\s*)*(?:[-*+]|\d+[.)]) \[[ xX]\]|^\s*\[[^\]]+\]:|^:::(?: \{\.presentation.*\})?$/im.test(text)) throw new Error("preserve") + if (parsed.childCount !== 1) throw new Error("preserve") + let node = schema.nodeFromJSON(parsed.firstChild.toJSON()) + node = node.type.create({ ...node.attrs, source: text, snapshot: signature(node) }, node.content, node.marks) + blocks.push(node) + } catch { blocks.push(preserved(text, ["bullet_list_open", "ordered_list_open"].includes(token.type) ? "list" : null)) } + consumed = end + } + if (consumed < source.length) blocks.push(preserved(source.slice(consumed))) } - if (cursor < markdown.length) blocks.push(preserved(markdown.slice(cursor))) return ensureTrailing(schema.topNodeType.create(null, blocks.length ? blocks : schema.nodes.paragraph.create())) } export function serializeDocument(doc, recordRange = null) { @@ -181,7 +305,15 @@ export function createRichDocument(element, markdown, changed, selectionChanged serializeDocument(view.state.doc, (pos, range) => ranges.set(pos, range)) rangeDoc = view.state.doc } - return ranges.get(position) + if (ranges.has(position)) return ranges.get(position) + const node = view.state.doc.nodeAt(position) + if (node?.type !== schema.nodes.preserved) return null + const source = serializeDocument(view.state.doc) + const escaped = node.attrs.source.trim().split("\n").map(line => line.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\n[ \\t]*(?:>[ \\t]*)*") + let occurrence = 0 + view.state.doc.descendants((child, pos) => { if (pos < position && child.type === node.type && child.attrs.source.trim() === node.attrs.source.trim()) occurrence++ }) + const match = Array.from(source.matchAll(new RegExp(escaped, "g")))[occurrence] + return match ? { from: match.index, to: match.index + match[0].length } : null } const state = EditorState.create({ doc: parseDocument(markdown), @@ -333,11 +465,28 @@ export function createRichDocument(element, markdown, changed, selectionChanged view.dispatch(view.state.tr.setNodeMarkup(position, null, { ...node.attrs, params: language })) return true }, - sourceRange(position) { - let result = null - serializeDocument(view.state.doc, (pos, range) => { if (pos === position) result = range }) - return result + deleteContentBlock(position) { + const node = view.state.doc.nodeAt(position) + if (node?.type !== schema.nodes.preserved) return false + const tr = closeHistory(view.state.tr.delete(position, position + node.nodeSize)) + tr.setSelection(TextSelection.near(tr.doc.resolve(Math.min(position, tr.doc.content.size)))) + view.dispatch(tr); view.focus() + return true + }, + setBlockSource(position, source) { + const node = view.state.doc.nodeAt(position) + if (node?.type !== schema.nodes.preserved) return false + view.dispatch(view.state.tr.setNodeMarkup(position, null, { ...node.attrs, source })) + return true }, + replaceSource(from, to, source) { + const current = serializeDocument(view.state.doc) + const next = parseDocument(current.slice(0, from) + source + current.slice(to)) + const tr = view.state.tr + patchChildren(tr, view.state.doc, next, 0) + view.dispatch(tr) + }, + sourceRange(position) { return sourceRangeAt(view, position) }, command(name, value) { const commands = { bold: toggleMark(schema.marks.strong), italic: toggleMark(schema.marks.em), @@ -597,18 +746,131 @@ function previewSettled(body, view, getPos, sourceRangeAt) { function sourceNodeView(initial, view, getPos, preview, sourceRangeAt) { let node = initial, generation = 0 - const { dom, title } = blockChrome(getPos, "Markdown block") + const { dom, title, edit, copy } = blockChrome(getPos, "Markdown block") + if (node.attrs.kind === "reference") { + dom.hidden = true + return { dom, update: next => next.type === node.type && next.attrs.kind === "reference", + stopEvent: () => true, ignoreMutation: () => true } + } + if (["presentation", "iframe"].includes(node.attrs.kind)) { + dom.classList.add("document-editor__content-block") + dom.contentEditable = "false" + title.textContent = node.attrs.kind === "presentation" ? "Presentation" : "Embedded page" + edit.remove() + const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "×" + remove.setAttribute("aria-label", node.attrs.kind === "presentation" ? "Remove presentation" : "Remove embedded page") + remove.dataset.action = "coplan--editor#deleteContentBlock" + dom.querySelector(".document-editor__block-header").append(remove) + if (node.attrs.kind === "presentation") { + title.className = "document-editor__presentation-label" + const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg") + icon.setAttribute("viewBox", "0 0 24 24"); icon.setAttribute("aria-hidden", "true") + const path = document.createElementNS("http://www.w3.org/2000/svg", "path") + path.setAttribute("d", "M3 3h18v13H3z M12 16v5 M8 21h8 M7 7h10 M7 11h6") + icon.append(path); title.prepend(icon) + const toggle = document.createElement("button"); toggle.type = "button"; toggle.textContent = "Preview" + toggle.dataset.action = "coplan--editor#togglePresentationPreview" + toggle.setAttribute("aria-expanded", "false") + dom.querySelector(".document-editor__block-header").insertBefore(toggle, copy) + const body = document.createElement("div"); body.className = "document-editor__block-preview"; body.hidden = true + const input = document.createElement("textarea") + input.className = "document-editor__block-source" + input.setAttribute("aria-label", "Presentation Markdown") + input.dataset.action = "input->coplan--editor#blockSourceChanged keydown->coplan--editor#blockKeydown" + input.value = presentationContent(node.attrs.source).content + dom.append(input, body) + dom.coplanTogglePreview = async () => { + const showing = body.hidden + toggle.setAttribute("aria-expanded", String(showing)) + toggle.textContent = showing ? "Edit Markdown" : "Preview" + input.hidden = showing; body.hidden = !showing + const sequence = ++generation + if (!showing) { input.focus({ preventScroll: true }); return } + body.textContent = "Loading preview…"; body.setAttribute("aria-busy", "true") + try { + const definitions = [] + view.state.doc.descendants(item => { + if (item.attrs.kind === "reference") definitions.push(item.attrs.source) + }) + const html = await preview([node.attrs.source, ...definitions].join("\n\n")) + if (sequence === generation) body.innerHTML = html + } catch { + if (sequence === generation) body.textContent = "Preview unavailable. Return to Markdown to keep editing." + } finally { + if (sequence === generation) body.removeAttribute("aria-busy") + } + } + return { dom, update(next) { + if (next.type !== node.type || next.attrs.kind !== "presentation") return false + node = next + const content = presentationContent(node.attrs.source).content + if (input.value !== content) { + const start = input.selectionStart, end = input.selectionEnd + input.value = content + input.setSelectionRange(Math.min(start, content.length), Math.min(end, content.length)) + } + return true + }, stopEvent: () => true, ignoreMutation: () => true, destroy() { generation++ } } + } + copy.remove() + const fields = document.createElement("div"); fields.className = "document-editor__embed-fields" + const attrs = parseIframeSource(node.attrs.source) + for (const [name, label, fallback] of [["src", "URL", ""], ["title", "Title", "Embedded content"], ["width", "Width", "100%"], ["height", "Height", "480"]]) { + const wrapper = document.createElement("label"); wrapper.textContent = label + const input = document.createElement("input"); input.value = attrs[name] || fallback + input.dataset.embedAttribute = name + input.dataset.action = "input->coplan--editor#embedChanged keydown->coplan--editor#blockKeydown" + input.setAttribute("aria-label", `Embedded page ${name === "src" ? "URL" : label.toLowerCase()}`) + if (name === "src") input.inputMode = "url" + wrapper.append(input); fields.append(wrapper) + } + const previewBody = document.createElement("div"); previewBody.className = "document-editor__block-preview" + const hint = document.createElement("p"); hint.className = "document-editor__embed-hint" + const form = view.dom.closest("[data-coplan--editor-embed-domains-value]") + const hosts = JSON.parse(form?.dataset.coplanEditorEmbedDomainsValue || form?.getAttribute("data-coplan--editor-embed-domains-value") || "[]") + hint.textContent = (hosts.length ? `Approved domains: ${hosts.join(", ")}. HTTPS only.` : "No iframe domains are approved. An administrator can add them in CoPlan Admin.") + " Title describes the page for screen readers; it is not a visible caption." + dom.append(fields, hint, previewBody) + let previewTimer + const render = () => { + clearTimeout(previewTimer) + const sequence = ++generation, source = node.attrs.source + previewTimer = setTimeout(async () => { + try { const html = await preview?.(source); if (html && sequence === generation) previewBody.innerHTML = html } + catch { if (sequence === generation) previewBody.textContent = "Preview unavailable" } + }, 300) + } + render() + return { dom, update(next) { + if (next.type !== node.type || next.attrs.kind !== "iframe") return false + node = next + const attrs = parseIframeSource(node.attrs.source) + fields.querySelectorAll("input").forEach(input => { const value = attrs[input.dataset.embedAttribute] || ""; if (input.value !== value) input.value = value }) + render(); return true + }, stopEvent: () => true, ignoreMutation: () => true, destroy() { generation++; clearTimeout(previewTimer) } } + } + dom.contentEditable = "false" + if (node.attrs.kind === "table") { + edit.textContent = "Edit table" + edit.dataset.action = "coplan--editor#toggleBlockSource" + const input = document.createElement("textarea"); input.className = "document-editor__block-source" + input.setAttribute("aria-label", "Table Markdown"); input.hidden = true; input.value = node.attrs.source + input.dataset.action = "input->coplan--editor#blockSourceChanged keydown->coplan--editor#blockKeydown" + dom.append(input) + } const body = document.createElement("div"); body.className = "document-editor__block-preview"; dom.append(body) const render = async () => { const sequence = ++generation, source = node.attrs.source dom.hidden = !source.trim() if (dom.hidden) return - const isTable = /^\s*\|?.*\|.*\n\s*\|?\s*:?-{3,}/m.test(source) - title.textContent = isTable ? "Table" : "Markdown block" + const isTable = node.attrs.kind !== "list" && /^\s*\|?.*\|.*\n\s*\|?\s*:?-{3,}/m.test(source) + const isReferences = /^\s*\[\^[^\]]+\]:/m.test(source) + title.textContent = node.attrs.kind === "list" ? "List" : isTable ? "Table" : isReferences ? "References" : "Markdown block" body.replaceChildren() const fallback = document.createElement("pre"); fallback.textContent = source; body.append(fallback) - if (preview) { + // Rendering definitions alone drops them as unused footnotes. Keep their + // source visible so References cards never become empty previews. + if (preview && !isReferences) { try { const html = await preview(source) if (sequence === generation) { body.innerHTML = html; previewSettled(body, view, getPos, sourceRangeAt) } @@ -616,7 +878,12 @@ function sourceNodeView(initial, view, getPos, preview, sourceRangeAt) { } } render() - return { dom, update(next) { if (next.type !== node.type) return false; if (next.attrs.source !== node.attrs.source) { node = next; render() } return true }, + return { dom, update(next) { if (next.type !== node.type || next.attrs.kind !== node.attrs.kind) return false; if (next.attrs.source !== node.attrs.source) { + node = next + const input = dom.querySelector("textarea") + if (input && input.value !== node.attrs.source) input.value = node.attrs.source + render() + } return true }, stopEvent: () => true, ignoreMutation: () => true, destroy() { generation++ } } } function codeNodeView(initial, view, getPos, preview, sourceRangeAt) { @@ -698,3 +965,16 @@ export function createMarkdownDocument(element, source, changed) { } } } + +export function parseIframeSource(source) { + const attrs = {} + for (const match of source.matchAll(/([a-z]+)="([^"]*)"/g)) { + const decoder = document.createElement("textarea"); decoder.innerHTML = match[2] + attrs[match[1]] = decoder.value + } + return attrs +} +export function iframeSource(attrs) { + const escape = value => String(value).replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<") + return `::: {.iframe ${["src", "title", "width", "height"].map(name => `${name}="${escape(attrs[name])}"`).join(" ")} /}\n` +} diff --git a/engine/app/models/coplan/embed_domain.rb b/engine/app/models/coplan/embed_domain.rb new file mode 100644 index 00000000..a7a1f5c4 --- /dev/null +++ b/engine/app/models/coplan/embed_domain.rb @@ -0,0 +1,19 @@ +module CoPlan + # A host approved by an administrator for embedded content. Approval is + # exact: approving example.com does not also approve its subdomains. + class EmbedDomain < ApplicationRecord + HOSTNAME = /\A(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?\z/ + before_validation { self.hostname = hostname.to_s.strip.downcase } + validates :hostname, presence: true, length: { maximum: 253 }, + format: { with: HOSTNAME, message: "must be an exact hostname, without a URL, port or wildcard" }, + uniqueness: { case_sensitive: false } + + def self.ransackable_attributes(_auth = nil) + %w[id hostname created_at updated_at] + end + + def self.ransackable_associations(_auth = nil) + [] + end + end +end diff --git a/engine/app/services/coplan/content_regions/iframe.rb b/engine/app/services/coplan/content_regions/iframe.rb new file mode 100644 index 00000000..ad7bc399 --- /dev/null +++ b/engine/app/services/coplan/content_regions/iframe.rb @@ -0,0 +1,61 @@ +require "uri" +require "cgi" + +module CoPlan + module ContentRegions + class Iframe + PREFIX = "::: {.iframe " + ATTRIBUTES = %w[src title width height].freeze + DEFAULTS = { "title" => "Embedded content", "width" => "100%", "height" => "480" }.freeze + + def self.call(source) + return unless source.start_with?(PREFIX) && source.end_with?(" /}") + + rest = source.delete_prefix(PREFIX).delete_suffix(" /}") + attrs = {} + until rest.empty? + match = /\A([a-z]+)="([^"]*)"(?: +|\z)/.match(rest) + return unless match && ATTRIBUTES.include?(match[1]) && !attrs.key?(match[1]) + + attrs[match[1]] = CGI.unescapeHTML(match[2]) + rest = rest[match[0].length..] + end + return unless attrs["src"].present? + + new(DEFAULTS.merge(attrs)) + end + + attr_reader :attributes + + def initialize(attributes) + @attributes = attributes + end + + def valid? + uri = URI.parse(attributes["src"]) + uri.is_a?(URI::HTTPS) && uri.host.present? && uri.userinfo.nil? && uri.port == 443 && + !attributes["src"].match?(/[\\\s]/) && + attributes["title"].present? && attributes["title"].length <= 200 && + (attributes["width"].match?(/\A(?:[1-9]\d?|100)%\z/) || pixel_size?(attributes["width"], 240..2400)) && + pixel_size?(attributes["height"], 160..1600) + rescue URI::InvalidURIError + false + end + + def allowed?(host: nil) + return false unless valid? + + hostname = URI.parse(attributes["src"]).host.downcase + return false if hostname == host.to_s.downcase + + EmbedDomain.exists?(hostname: hostname) + end + + private + + def pixel_size?(value, range) + value.match?(/\A\d{3,4}\z/) && range.cover?(value.to_i) + end + end + end +end diff --git a/engine/app/services/coplan/references/extract_from_content.rb b/engine/app/services/coplan/references/extract_from_content.rb index dffc0d4c..e9dc97cf 100644 --- a/engine/app/services/coplan/references/extract_from_content.rb +++ b/engine/app/services/coplan/references/extract_from_content.rb @@ -54,6 +54,9 @@ def remove_all_extracted end def extract_urls(content) + # Embedded URLs describe a content block, not a citation. In particular, + # a quoted src must not create a bare-URL reference ending in a quote. + content = content.lines.reject { |line| ContentRegions::Iframe.call(line.chomp) }.join urls = {} # url => { title:, key: } # Match markdown reference-style link definitions: [key]: url "optional title" 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 3d07212e..94bf903d 100644 --- a/engine/app/views/coplan/agent_instructions/guides/api.text.erb +++ b/engine/app/views/coplan/agent_instructions/guides/api.text.erb @@ -81,3 +81,7 @@ Guides: `<%= @base %>/agent-instructions/`, for example <<%= @base %>/age | `404` | The plan or thread does not exist. | | `409` | A stale `base_revision`, a hand edit that you have not read (`"code": "human_edit_pending"`), or someone holds the edit lock (`"code": "edit_locked"`). See the editing guide. | | `422` | The request is not valid, or an operation cannot apply. The `error` field says why. | + +## Iframe domains + +`GET /api/v1/embed_domains` returns `{"hostnames":["embed.example.com"]}`. This is the exact-host allowlist for iframe content blocks. It requires your Bearer token. An empty list means no hosts are approved. Only an administrator can change the list, through CoPlan Admin. See <<%= @base %>/agent-instructions/embeds> for block syntax. diff --git a/engine/app/views/coplan/agent_instructions/guides/creating.text.erb b/engine/app/views/coplan/agent_instructions/guides/creating.text.erb index e28130a8..b3659d2b 100644 --- a/engine/app/views/coplan/agent_instructions/guides/creating.text.erb +++ b/engine/app/views/coplan/agent_instructions/guides/creating.text.erb @@ -64,7 +64,7 @@ To reorganize a library, or to move many plans, fetch <<%= @base %>/agent-instru ## References -References are the list of resources connected to a plan: repositories, pull requests, documents, and other plans. Readers see them in the plan's References section. CoPlan adds every link in the content to this list. Add a reference through the API only for a resource that the content does not link. +References are the list of resources connected to a plan: repositories, pull requests, documents, and other plans. Readers see them in the plan's References section. CoPlan adds content links to this list. Iframe src URLs describe embedded content and do not become references. Add a reference through the API only for a resource that the content does not link. ```bash <%= @curl %> -X POST \ diff --git a/engine/app/views/coplan/agent_instructions/guides/embeds.text.erb b/engine/app/views/coplan/agent_instructions/guides/embeds.text.erb new file mode 100644 index 00000000..6513e13f --- /dev/null +++ b/engine/app/views/coplan/agent_instructions/guides/embeds.text.erb @@ -0,0 +1,24 @@ +# Embedded pages + +A plan can contain embedded pages. Use one self-closing block on its own line, with a blank line before and after: + +```markdown +::: {.iframe src="https://www.openstreetmap.org/export/embed.html" title="Area map" width="100%" height="480" /} +``` + +This block has no body or closing fence. Do not write raw HTML iframe tags; CoPlan removes them. + +- `src` is required. It must be HTTPS, with no credentials, backslashes, whitespace, or nonstandard port. +- Check allowed hosts with `GET /api/v1/embed_domains`, using your Bearer token. The response is `{"hostnames":["embed.example.com"]}`. +- An administrator must approve the exact hostname in **CoPlan Admin → Iframe domains**. Subdomains need separate approval. No domains are approved by default. +- `title` describes the embedded page for assistive technology. Default: `Embedded content`. Maximum: 200 characters. +- `width` is a percentage from `1%` to `100%`, or pixels from `240` to `2400`. Default: `100%`. The frame never grows wider than the document. +- `height` is pixels from `160` to `1600`. Default: `480`. +- Quote all values. Encode a quote as `"` and an ampersand as `&`. +- Unknown or duplicate attributes make the block invalid. Authors cannot set sandbox privileges, permissions, HTML, or scripts. + +Readers see an unavailable block for unapproved domains or invalid settings. Revoking a domain stops embeds on the next page render. Reload a page after adding an approved domain so its frame policy updates. Redirects are constrained to approved hosts. The app's own host is excluded. A site can still refuse embedding through its own browser policy. + +Frames use a sandbox with scripts, forms, and the embedded site’s own origin enabled. The app’s own host cannot be embedded. Top-level navigation, camera, microphone, and location are disabled. Cross-origin HTTPS requests send only the referring origin, not the plan path. CoPlan does not fetch the page on the server. + +In the human editor, **+ → Embed** adds a settings block. Change its URL, title, width, and height in place. These edits autosave and create normal plan versions. Presentation blocks instead contain one large Markdown field for their slide content. 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 f93f8c58..b8b1a188 100644 --- a/engine/app/views/coplan/agent_instructions/guides/markdown.text.erb +++ b/engine/app/views/coplan/agent_instructions/guides/markdown.text.erb @@ -31,11 +31,11 @@ Readers see a preview of the section on hover. CoPlan does not link a bare `§3. ## Footnotes and links -- **To support a claim, use a footnote.** Put the source link and the supporting detail in the definition: `Queue depth peaked at 40k.[^queue-depth]` and `[^queue-depth]: Measured during the 2026-08-12 incident. [Dashboard](https://example.com/d/queues).` Readers can preview the note on hover. Use a descriptive footnote name, not a number. +- **To support a claim, use a footnote.** Put the source link and the supporting detail in the definition: `Queue depth peaked at 40k.[^queue-depth]` and `[^queue-depth]: Measured during the 2026-08-12 incident. [Dashboard](https://example.com/d/queues).` Readers can preview the note on hover. Use a descriptive footnote name, not a number. In the human inline editor, citations stay inline in editable prose and lists. Click a citation (or Edit beside its reference at the bottom) to edit its definition in a dialog. Save stores it in the Markdown definitions; these definitions stay hidden in the editor body. - **To point to a resource, use a descriptive link:** `[implementation PR](https://github.com/org/repo/pull/42)`. Do not put bare URLs in prose. - **To give a link a stable name, use a reference-style link:** `See [auth-repo].` with `[auth-repo]: https://github.com/org/auth-service "Auth Service"`. The key and title go to the plan's References. -CoPlan adds every link in the content, including links in footnotes, to the plan's References section. Do not add these links again through the API. See "References" in <<%= @base %>/agent-instructions/creating>. +CoPlan adds content links, including links in footnotes, to the plan's References section. Iframe src URLs describe embedded content and do not become references. Do not add these links again through the API. See "References" in <<%= @base %>/agent-instructions/creating>. ## Diagrams diff --git a/engine/app/views/coplan/agent_instructions/guides/presentations.text.erb b/engine/app/views/coplan/agent_instructions/guides/presentations.text.erb index 4afe695f..559b4a29 100644 --- a/engine/app/views/coplan/agent_instructions/guides/presentations.text.erb +++ b/engine/app/views/coplan/agent_instructions/guides/presentations.text.erb @@ -33,6 +33,8 @@ Analysis after the deck. Another deck can follow later in the plan. - CoPlan shows a region marker that it does not recognize, or a region that is not closed, as plain text. If the deck does not appear, check the marker lines. - The `Presentation` plan type's template starts with a deck region. Use that type when the whole plan is a deck. To add a deck to another plan, add a region. +In the human inline editor, use **+ → Presentation** to insert a deck at the caret. Each presentation is one block with a large Markdown field. Edit the slide headings, text, and separators there. CoPlan keeps the enclosing region markers in the source. Use Preview to see the slide layouts and navigate slides without leaving the editor. Edit Markdown returns to the source field. Reading view uses the same layouts. + ## Slides - Inside a region, a line of three or more dashes (`---`) starts a new slide. Put a blank line before it. Without the blank line, Markdown makes the line above it a heading. diff --git a/engine/app/views/coplan/agent_instructions/show.text.erb b/engine/app/views/coplan/agent_instructions/show.text.erb index 7adb7b27..3d5aab5a 100644 --- a/engine/app/views/coplan/agent_instructions/show.text.erb +++ b/engine/app/views/coplan/agent_instructions/show.text.erb @@ -147,6 +147,7 @@ Fetch a guide when your task needs it. Each guide is Markdown, like this page. |---|---| | Diagrams, tables, code blocks, footnotes, section links, task lists, images | <<%= @base %>/agent-instructions/markdown> | | Slide decks inside a plan (`::: {.presentation}` regions) | <<%= @base %>/agent-instructions/presentations> | +- Embedded pages: <<%= @base %>/agent-instructions/embeds> | Plan types, folders, tags, references, visibility, archiving | <<%= @base %>/agent-instructions/creating> | | Conflicts, human edits, targeted edits, title, tag, and type changes, version history | <<%= @base %>/agent-instructions/editing> | | Comments, mentions, table and diagram comments, reviewing a plan | <<%= @base %>/agent-instructions/comments> | diff --git a/engine/app/views/coplan/plans/_citations.html.erb b/engine/app/views/coplan/plans/_citations.html.erb index 740e9a2f..d999425d 100644 --- a/engine/app/views/coplan/plans/_citations.html.erb +++ b/engine/app/views/coplan/plans/_citations.html.erb @@ -1,4 +1,4 @@
- <%= back_matter[:html] %> + <%= citation_edit_controls(back_matter[:html]) %>
diff --git a/engine/app/views/coplan/plans/_content_body.html.erb b/engine/app/views/coplan/plans/_content_body.html.erb index 51848ae6..608d466a 100644 --- a/engine/app/views/coplan/plans/_content_body.html.erb +++ b/engine/app/views/coplan/plans/_content_body.html.erb @@ -7,6 +7,6 @@ content-mutation broadcast. skip_digest avoids the template-digest cost on write paths; the explicit RENDER_CACHE_VERSION key handles renderer changes instead. -%> -<% cache ["coplan/plan-content-body", CoPlan::MarkdownHelper::RENDER_CACHE_VERSION, plan.id, plan.current_plan_version&.content_sha256 || plan.current_revision], skip_digest: true do %> +<% cache [CoPlan::EmbedDomain.order(:hostname).pluck(:hostname), request&.host, "coplan/plan-content-body", CoPlan::MarkdownHelper::RENDER_CACHE_VERSION, plan.id, plan.current_plan_version&.content_sha256 || plan.current_revision], skip_digest: true do %> <%= render_content_regions(plan.current_content) %> <% end %> diff --git a/engine/app/views/coplan/plans/_editor_form.html.erb b/engine/app/views/coplan/plans/_editor_form.html.erb index 7b9a71a0..28eb44e5 100644 --- a/engine/app/views/coplan/plans/_editor_form.html.erb +++ b/engine/app/views/coplan/plans/_editor_form.html.erb @@ -8,6 +8,7 @@ "coplan--editor-revision-value": @base_revision, "coplan--editor-state-url-value": creating ? "" : editor_state_plan_path(@plan), "coplan--editor-lease-url-value": creating ? "" : editor_lease_plan_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 %> <%= hidden_field_tag :plan_type_id, @new_plan_type&.id %> @@ -75,6 +76,13 @@ +
+ +
+ + +
+
<% [["bold", "Bold", "⌘B / Ctrl+B", "B"], ["italic", "Italic", "⌘I / Ctrl+I", "I"], ["code", "Inline code", "", ""], ["link", "Link", "⌘K / Ctrl+K", ""]].each do |command, label, shortcut, icon| %> <% end %> @@ -112,6 +120,19 @@ Opening document… <% end %> + +
+

Citation

+ +
+ + + +
+ + +
+
<% CoPlan.configuration.editor_code_languages.each do |language| %>
data-action="mouseup->coplan--text-selection#handleEditorMouseUp"<% end %>> diff --git a/engine/config/routes.rb b/engine/config/routes.rb index ac8d0906..0c742b72 100644 --- a/engine/config/routes.rb +++ b/engine/config/routes.rb @@ -136,6 +136,7 @@ # Plan-type catalog (with templates) — agents read this before # creating a plan; see the Create Plan section of /agent-instructions. resources :plan_types, only: [ :index ] + resources :embed_domains, only: [ :index ] resources :folders, only: [ :index, :create, :update, :destroy ] # The agent organization API: overview (show), bulk read (contents), diff --git a/engine/db/migrate/20261008000000_create_coplan_embed_domains.rb b/engine/db/migrate/20261008000000_create_coplan_embed_domains.rb new file mode 100644 index 00000000..e2413522 --- /dev/null +++ b/engine/db/migrate/20261008000000_create_coplan_embed_domains.rb @@ -0,0 +1,9 @@ +class CreateCoplanEmbedDomains < ActiveRecord::Migration[8.0] + def change + create_table :coplan_embed_domains, id: :string, limit: 36 do |t| + t.string :hostname, null: false + t.timestamps + end + add_index :coplan_embed_domains, :hostname, unique: true + end +end diff --git a/spec/helpers/embeds_helper_spec.rb b/spec/helpers/embeds_helper_spec.rb new file mode 100644 index 00000000..8db52d82 --- /dev/null +++ b/spec/helpers/embeds_helper_spec.rb @@ -0,0 +1,37 @@ +require "rails_helper" +RSpec.describe CoPlan::MarkdownHelper, type: :helper do + let(:marker) { '::: {.iframe src="https://embed.example.com/view?a=1&b=2" title="Example" width="80%" height="600" /}' } + def render(source) + Nokogiri::HTML::DocumentFragment.parse(helper.render_markdown(source, interactive: false)) + end + it "renders only approved content blocks with fixed sandbox and permissions" do + CoPlan::EmbedDomain.create!(hostname: 'embed.example.com') + frame = render(marker).at_css('iframe') + expect(frame['src']).to eq('https://embed.example.com/view?a=1&b=2') + expect(frame['title']).to eq('Example') + expect(frame['sandbox']).to eq('allow-scripts allow-forms allow-same-origin') + expect(frame['referrerpolicy']).to eq('strict-origin-when-cross-origin') + expect(frame['style']).to eq('width: 80%') + expect(frame['height']).to eq('600') + expect(render(marker).css('[data-sourcepos]')).to be_empty + end + it "does not give an approved frame access to the application origin" do + CoPlan::EmbedDomain.create!(hostname: 'embed.example.com') + allow(helper.request).to receive(:host).and_return('embed.example.com') + expect(render(marker).css('iframe')).to be_empty + end + it "shows unavailable embeds without loading their URL, including after revocation" do + expect(render(marker).css('iframe')).to be_empty + domain = CoPlan::EmbedDomain.create!(hostname: 'embed.example.com') + expect(render(marker).css('iframe').size).to eq(1) + domain.destroy! + expect(render(marker).css('iframe')).to be_empty + expect(render(marker).text).to include('Embedded page unavailable') + end + it "keeps examples and arbitrary raw HTML from creating frames" do + CoPlan::EmbedDomain.create!(hostname: 'embed.example.com') + [ "`#{marker}`", "```markdown\n#{marker}\n```", "> #{marker}", "- #{marker}", '' ].each do |source| + expect(render(source).css('iframe')).to be_empty + end + end +end diff --git a/spec/models/coplan/embed_domain_spec.rb b/spec/models/coplan/embed_domain_spec.rb new file mode 100644 index 00000000..e4ada0cd --- /dev/null +++ b/spec/models/coplan/embed_domain_spec.rb @@ -0,0 +1,12 @@ +require "rails_helper" +RSpec.describe CoPlan::EmbedDomain do + it "normalizes exact DNS hostnames and rejects URLs, wildcards, paths and IP addresses" do + domain = described_class.create!(hostname: " EMBED.Example.COM ") + expect(domain.hostname).to eq("embed.example.com") + expect(domain.id).to be_present + expect(described_class.new(hostname: "embed.example.com")).not_to be_valid + [ "https://embed.example.com", "*.example.com", "example.com/path", "example.com:443", "127.0.0.1", "localhost", "" ].each do |host| + expect(described_class.new(hostname: host)).not_to be_valid + end + end +end diff --git a/spec/requests/admin_pages_spec.rb b/spec/requests/admin_pages_spec.rb index e2bd2f9e..74c41dd9 100644 --- a/spec/requests/admin_pages_spec.rb +++ b/spec/requests/admin_pages_spec.rb @@ -21,6 +21,7 @@ "plan_comments" => -> { create(:comment) }, "edit_leases" => -> { create(:edit_lease) }, "edit_sessions" => -> { create(:edit_session) }, + "embed_domains" => -> { CoPlan::EmbedDomain.create!(hostname: "embed.example.com") }, "folders" => -> { create(:folder) }, "library_events" => -> { CoPlan::LibraryEvent.create!( diff --git a/spec/requests/api/v1/embed_domains_spec.rb b/spec/requests/api/v1/embed_domains_spec.rb new file mode 100644 index 00000000..d8a73bf8 --- /dev/null +++ b/spec/requests/api/v1/embed_domains_spec.rb @@ -0,0 +1,13 @@ +require "rails_helper" +RSpec.describe "Api::V1::EmbedDomains", type: :request do + it "requires a token and returns only exact hostnames in sorted order" do + get api_v1_embed_domains_path + expect(response).to have_http_status(:unauthorized) + create(:api_token, user: create(:coplan_user), raw_token: 'embed-test-token') + CoPlan::EmbedDomain.create!(hostname: 'z.example.com') + CoPlan::EmbedDomain.create!(hostname: 'a.example.com') + get api_v1_embed_domains_path, headers: { 'Authorization' => 'Bearer embed-test-token' } + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)).to eq('hostnames' => %w[a.example.com z.example.com]) + end +end diff --git a/spec/requests/embed_domains_spec.rb b/spec/requests/embed_domains_spec.rb new file mode 100644 index 00000000..2d545b57 --- /dev/null +++ b/spec/requests/embed_domains_spec.rb @@ -0,0 +1,28 @@ +require "rails_helper" +RSpec.describe "Iframe domain administration", type: :request do + it "lets only admins approve and remove a domain" do + sign_in_as(create(:coplan_user)) + post '/_/admin/embed_domains', params: { embed_domain: { hostname: 'embed.example.com' } } + expect(response).to have_http_status(:forbidden) + expect(CoPlan::EmbedDomain.count).to eq(0) + sign_in_as(create(:coplan_user, admin: true)) + post '/_/admin/embed_domains', params: { embed_domain: { hostname: 'embed.example.com' } } + expect(response).to have_http_status(:redirect) + domain = CoPlan::EmbedDomain.find_by!(hostname: 'embed.example.com') + delete "/_/admin/embed_domains/#{domain.id}" + expect(response).to have_http_status(:redirect) + expect(CoPlan::EmbedDomain.count).to eq(0) + end + it "restricts iframe destinations and composes with an existing host policy" do + sign_in_as(create(:coplan_user)) + CoPlan::EmbedDomain.create!(hostname: 'embed.example.com') + host_policy = ActionDispatch::ContentSecurityPolicy.new { |policy| policy.default_src :self; policy.frame_src :none } + allow_any_instance_of(ActionDispatch::Request).to receive(:content_security_policy).and_return(host_policy) + get settings_root_path + expect(response.headers['Content-Security-Policy']).to include("default-src 'self'; frame-src 'none'") + expect(response.headers['Content-Security-Policy']).to include('frame-src https://embed.example.com') + CoPlan::EmbedDomain.delete_all + get settings_root_path + expect(response.headers['Content-Security-Policy']).to include("frame-src 'none'") + end +end diff --git a/spec/requests/human_document_editor_spec.rb b/spec/requests/human_document_editor_spec.rb index bac561ff..0e17a4d2 100644 --- a/spec/requests/human_document_editor_spec.rb +++ b/spec/requests/human_document_editor_spec.rb @@ -160,6 +160,22 @@ def acquire expect(response).to have_http_status(:not_found) end + it "refreshes the editor security token without caching the snapshot" do + get editor_state_plan_path(plan), as: :json + expect(response).to have_http_status(:ok) + expect(response.headers["X-CSRF-Token"]).to be_present + expect(response.headers["Cache-Control"]).to include("no-store") + end + + it "previews presentations using the slideshow renderer without saving" do + expect { + post preview_draft_plans_path, params: { content: "::: {.presentation}\n\n# Opening\n\n---\n\n# Decision\n\n:::\n" } + }.not_to change(CoPlan::PlanVersion, :count) + expect(response).to have_http_status(:ok) + expect(response.body).to include('deck-region', 'coplan--deck-reader', 'Opening', 'Decision') + expect(Nokogiri::HTML.fragment(response.body).css('.deck-slide').size).to eq(2) + end + it "previews tables and Mermaid without saving or executing raw HTML" do expect { post preview_draft_plans_path, params: { content: "| A | B |\n|---|---|\n| 1 | 2 |\n\n```mermaid\ngraph TD; A-->B\n```\n" } diff --git a/spec/services/content_regions/iframe_spec.rb b/spec/services/content_regions/iframe_spec.rb new file mode 100644 index 00000000..d8d2e06f --- /dev/null +++ b/spec/services/content_regions/iframe_spec.rb @@ -0,0 +1,25 @@ +require "rails_helper" +RSpec.describe CoPlan::ContentRegions::Iframe do + def block(src, rest = '') + described_class.call(%(::: {.iframe src="#{src}"#{rest} /})) + end + it "defaults sizes, checks HTTPS URLs and matches approved hosts exactly" do + CoPlan::EmbedDomain.create!(hostname: "embed.example.com") + expect(block('https://embed.example.com/view').allowed?).to be(true) + expect(block('https://sub.embed.example.com/view').allowed?).to be(false) + expect(block('https://embed.example.com.evil.test/view').allowed?).to be(false) + expect(block('https://embed.example.com/view').allowed?(host: 'embed.example.com')).to be(false) + [ 'http://embed.example.com/view', '//embed.example.com/view', 'javascript:alert(1)', 'https://user@embed.example.com', 'https://embed.example.com:8443/view', 'https://embed.example.com\\@evil.test' ].each do |src| + expect(block(src).allowed?).to be(false) + end + end + it "validates dimensions and never accepts author-controlled iframe privileges" do + expect(block('https://embed.example.com', ' width="80%" height="600"').valid?).to be(true) + [ ' width="101%"', ' width="10; color:red"', ' height="0"', ' height="9000"', ' width="9000"', ' title=""' ].each do |rest| + expect(block('https://embed.example.com', rest).valid?).to be(false) + end + expect(block('https://embed.example.com', ' sandbox="allow-same-origin"')).to be_nil + expect(block('https://embed.example.com', ' src="https://evil.test"')).to be_nil + expect(block('https://embed.example.com', ' title="A "quoted" title"').attributes['title']).to eq('A "quoted" title') + end +end diff --git a/spec/services/coplan/references/extract_from_content_spec.rb b/spec/services/coplan/references/extract_from_content_spec.rb index 290ba107..85b06b25 100644 --- a/spec/services/coplan/references/extract_from_content_spec.rb +++ b/spec/services/coplan/references/extract_from_content_spec.rb @@ -22,6 +22,14 @@ def update_content(plan, content) expect(ref.source).to eq("extracted") end + it "keeps iframe URLs out of references while extracting normal citations" do + source = '::: {.iframe src="https://embed.example.com/view?a=1&b=2" title="Report" /}' + + "\n\nClaim.[^source]\n\n[^source]: [Study](https://example.com/study).\n" + update_content(plan, source) + described_class.call(plan: plan) + expect(plan.references.reload.pluck(:url)).to eq([ 'https://example.com/study' ]) + end + it "classifies GitHub repo URLs" do update_content(plan, "See [repo](https://github.com/org/my-repo) for code.") described_class.call(plan: plan) diff --git a/spec/system/document_editor_modes_spec.rb b/spec/system/document_editor_modes_spec.rb index fbb97427..1120aa60 100644 --- a/spec/system/document_editor_modes_spec.rb +++ b/spec/system/document_editor_modes_spec.rb @@ -109,10 +109,9 @@ def block_saves expect(page).not_to have_button("Save") expect(page).to have_link("Done") expect(page).not_to have_content("Edit origin") - find(".document-editor__block", text: "Table").click_button("Edit Markdown") - expect(raw).to have_text("| Draft | Ready |") + find(".document-editor__block", text: "Table").click_button("Edit table") + expect(find_field("Table Markdown", enable_aria_label: true).value).to include("| Draft | Ready |") expect(page.evaluate_script('document.querySelector("textarea[name=content]").value')).to eq(source) - click_button "Editor" click_button "Raw", exact: true expect(page.evaluate_script('document.querySelector("textarea[name=content]").value')).to eq(source) save_now diff --git a/spec/system/human_editing_spec.rb b/spec/system/human_editing_spec.rb index 753ec973..b8fc945e 100644 --- a/spec/system/human_editing_spec.rb +++ b/spec/system/human_editing_spec.rb @@ -82,6 +82,276 @@ def save_now expect(results).to eq(fixtures) end + it "edits cited prose and lists without Markdown cards, retaining citations on save" do + source = "A catalog price.[^catalog-shape] Inline `[^literal]` stays code.\n\n" \ + "- A client rule.[^client]\n- Another rule.\n\n[^catalog-shape]: Catalog details.\n[^client]: Client details.\n" + CoPlan::Plans::ReplaceContent.call(plan: plan, new_content: source, + base_revision: plan.current_revision, actor_type: "human", actor_id: author.id) + visit plan_edit_page_path(plan) + editor + expect(page).to have_css(".ProseMirror p", text: "A catalog price.") + expect(page).to have_css(".ProseMirror li .document-editor__footnote", text: "[client]") + expect(page).to have_css(".ProseMirror code", text: "[^literal]") + expect(page).to have_no_css(".document-editor__block-header", text: "Markdown block") + expect(page).to have_no_css(".document-editor__block-header", text: "References", visible: true) + expect(page).to have_no_css(".document-editor__block-preview pre", text: "[^catalog-shape]: Catalog details.", visible: true) + original = page.evaluate_async_script(<<~'JS', source) + const [source, done] = arguments; + import("coplan/rich_document").then(m => done(m.serializeDocument(m.parseDocument(source)))); + JS + expect(original).to eq(source) + page.execute_script(<<~JS) + const form = document.querySelector('form.document-editor') + const view = window.Stimulus.getControllerForElementAndIdentifier(form, 'coplan--editor').richEditor.view + let paragraph, item + view.state.doc.descendants((node, pos) => { + if (node.isText && node.text.includes('A catalog price.')) paragraph = pos + if (node.isText && node.text === 'A client rule.') item = pos + }) + view.dispatch(view.state.tr.insertText('Resolved client rule.', item, item + 'A client rule.'.length)) + view.dispatch(view.state.tr.insertText('Resolved catalog price.', paragraph, paragraph + 'A catalog price.'.length)) + JS + save_now + expect(page).to have_content("All changes saved · v3") + expect(plan.reload.current_content).to include("Resolved catalog price.[^catalog-shape]", "Resolved client rule.[^client]", "`[^literal]`") + expect(plan.current_content).to end_with("[^catalog-shape]: Catalog details.\n[^client]: Client details.\n") + end + + it "edits a whole presentation in one Markdown field, preserving its wrapping region" do + source = "Before the deck.\n\n::: {.presentation #review theme=\"graphite\"}\n\n# Review\n\nOpening slide.\n\n---\n\n## Decision\n\n- Ship it.\n\n:::\n\nAfter the deck.\n" + CoPlan::Plans::ReplaceContent.call(plan: plan, new_content: source, + base_revision: plan.current_revision, actor_type: "human", actor_id: author.id) + visit plan_edit_page_path(plan) + editor + expect(page).to have_field("Presentation Markdown", enable_aria_label: true) + expect(page).to have_no_button("Edit boundary") + expect(page).to have_no_css(".document-editor__block-header", text: "Markdown block", visible: true) + field = find('[aria-label="Presentation Markdown"]') + field.fill_in with: field.value.sub("Opening slide.", "Updated slide.") + within(".document-editor__content-block") do + expect(page).to have_css(".document-editor__presentation-label svg") + click_button "Preview", exact: true + expect(page).to have_css(".deck-slide", text: "Updated slide.") + expect(page).to have_no_field("Presentation Markdown", enable_aria_label: true) + find('button[aria-label="Next slide"]').click + expect(page).to have_css(".deck-slide", text: "Decision") + click_button "Edit Markdown" + expect(page).to have_field("Presentation Markdown", enable_aria_label: true, with: /Updated slide/) + end + save_now + expect(page).to have_content("All changes saved · v3") + expect(plan.reload.current_content).to eq(source.sub("Opening slide.", "Updated slide.")) + end + + it "refreshes whole presentation blocks after source edits and retains copied citations" do + visit plan_edit_page_path(plan) + editor + result = page.evaluate_async_script(<<~'JS') + const done = arguments[0]; + Promise.all([import("coplan/rich_document"), import("prosemirror-model")]).then(([m, model]) => { + const host = document.createElement("div"); + const open = "::: {.presentation}\n\n# Review\n"; + const closed = open + "\n:::\n"; + const rich = m.createRichDocument(host, open, () => {}); + rich.update(closed); + const presentations = host.querySelectorAll('[aria-label="Presentation Markdown"]').length; + rich.update(open); + const unclosed = host.querySelectorAll('[aria-label="Presentation Markdown"]').length; + const windows = m.serializeDocument(m.parseDocument(closed.replaceAll('\n', '\r\n'))); + rich.update('Cited prose.[^catalog]'); + const copy = document.createElement('div'); + copy.append(host.querySelector('p').cloneNode(true)); + const pasted = model.DOMParser.fromSchema(rich.view.state.schema).parse(copy); + const citation = m.serializeDocument(pasted); + const plain = pasted.textBetween(0, pasted.content.size); + rich.destroy(); + done({ presentations, unclosed, windows, citation, plain }); + }); + JS + expect(result).to include("presentations" => 1, "unclosed" => 0, + "windows" => "::: {.presentation}\r\n\r\n# Review\r\n\r\n:::\r\n", + "citation" => "Cited prose.[^catalog]", "plain" => "Cited prose.[^catalog]") + end + + it "edits citations in a dialog while keeping definitions out of the body" do + source = "A catalog price.[^catalog] More context.[^other]\n\n[^catalog]: Catalog details.\n A second line.\n\n A second paragraph.\n[^other]: Keep this definition.\n" + CoPlan::Plans::ReplaceContent.call(plan: plan, new_content: source, + base_revision: plan.current_revision, actor_type: "human", actor_id: author.id) + visit plan_edit_page_path(plan) + editor + click_button "Edit citation catalog", enable_aria_label: true + expect(page).to have_css('dialog[open]', text: 'Citation: catalog') + expect(find('#coplan-citation-body').value).to eq("Catalog details.\nA second line.\n\nA second paragraph.") + fill_in "Citation text and source links", with: "Updated [Catalog source](https://example.com/catalog).\nMore detail." + click_button "Save citation" + expect(page).to have_no_css('dialog[open]') + expect(plan.reload.current_content).to include("[^catalog]: Updated [Catalog source](https://example.com/catalog).\n More detail.", "[^other]: Keep this definition.") + expect(page).to have_no_css('.ProseMirror pre', text: '[^catalog]:', visible: true) + click_button "Edit citation catalog", enable_aria_label: true + expect(find('#coplan-citation-body').value).to include('Updated [Catalog source]') + click_button "Cancel" + end + + it "keeps a list rich when it contains a table and edits just that table" do + source = "- First item.\n\n | Name | State |\n | --- | --- |\n | Alpha | Ready |\n\n- Second item.\n" + CoPlan::Plans::ReplaceContent.call(plan: plan, new_content: source, + base_revision: plan.current_revision, actor_type: "human", actor_id: author.id) + visit plan_edit_page_path(plan) + editor + expect(page).to have_css('.ProseMirror > ul > li', count: 2) + expect(page).to have_css('.ProseMirror li .document-editor__block-preview table td', text: 'Ready') + click_button "Edit table" + field = find('[aria-label="Table Markdown"]') + expect(field.value).not_to include('First item') + field.fill_in with: field.value.sub('Ready', 'Done') + save_now + expect(page).to have_content('All changes saved · v3') + expect(plan.reload.current_content).to include('First item.', 'Second item.', '| Alpha | Done |') + expect(plan.current_content).not_to include('Ready') + end + + it "keeps quoted tables in their quote and exposes their own source range" do + source = "> Explanation.\n>\n> | Name | Status |\n> | --- | --- |\n> | Alpha | Ready |\n\nAfter.\n" + CoPlan::Plans::ReplaceContent.call(plan: plan, new_content: source, + base_revision: plan.current_revision, actor_type: "human", actor_id: author.id) + visit plan_edit_page_path(plan) + editor + expect(page).to have_css('.ProseMirror blockquote table', text: 'Ready') + click_button 'Edit table' + field = find_field('Table Markdown', enable_aria_label: true) + expect(field.value).not_to include('>') + field.fill_in with: field.value.sub('Ready', 'Done') + save_now + expect(page).to have_content('All changes saved · v3') + expect(plan.reload.current_content).to include('> | Alpha | Done |', '> Explanation.', 'After.') + expect(plan.current_content).not_to include('> >') + range_source = page.evaluate_script(<<~'JS') + (() => { + const editor = Stimulus.getControllerForElementAndIdentifier(document.querySelector('form.document-editor'), 'coplan--editor').richEditor; + let position; editor.view.state.doc.descendants((node, pos) => { if (node.attrs.kind === 'table') position = pos; }); + const range = editor.sourceRange(position); + return range ? editor.content().slice(range.from, range.to) : null; + })() + JS + expect(range_source).to include('| Alpha | Done |') + end + + it "edits iframe settings and keeps disallowed URLs from loading" do + source = '::: {.iframe src="https://unapproved.example.com/report" title="Report" width="100%" height="480" /}' + "\n" + CoPlan::Plans::ReplaceContent.call(plan: plan, new_content: source, + base_revision: plan.current_revision, actor_type: "human", actor_id: author.id) + visit plan_edit_page_path(plan) + editor + expect(page).to have_field('Embedded page URL', enable_aria_label: true, with: 'https://unapproved.example.com/report') + expect(page).to have_content('Embedded page unavailable') + expect(page).to have_no_css('.document-editor__block-preview iframe') + fill_in 'Embedded page height', enable_aria_label: true, with: '600' + fill_in 'Embedded page title', enable_aria_label: true, with: 'Updated report' + save_now + expect(page).to have_content('All changes saved · v3') + expect(plan.reload.current_content).to include('height="600"', 'title="Updated report"') + end + + it "inserts an editable presentation from the content menu at the caret" do + visit plan_edit_page_path(plan) + editor + find(".ProseMirror > p", match: :first).click + click_button "Insert content", enable_aria_label: true + expect(page).to have_button("Presentation", exact: true) + expect(page).to have_button("Embed", exact: true) + click_button "Presentation", exact: true + expect(page).to have_no_css("#coplan-insert-content:popover-open") + field = find('[aria-label="Presentation Markdown"]:focus') + field.fill_in with: "# New presentation\n\nA useful slide." + save_now + expect(page).to have_content("All changes saved · v2") + expect(plan.reload.current_content).to include("::: {.presentation}", "# New presentation", "A useful slide.", ":::") + find('[aria-label="Remove presentation"]').click + page.execute_script("window.Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller~=\"coplan--editor\"]'), 'coplan--editor').richEditor.command('undo')") + expect(page).to have_field("Presentation Markdown", enable_aria_label: true, with: /A useful slide/) + end + + it "adds content after a selected presentation without replacing it" do + source = "::: {.presentation}\n\n# Existing deck\n\n:::\n" + CoPlan::Plans::ReplaceContent.call(plan: plan, new_content: source, + base_revision: plan.current_revision, actor_type: "human", actor_id: author.id) + visit plan_edit_page_path(plan) + editor + page.evaluate_async_script(<<~'JS') + const done = arguments[0]; + import('prosemirror-state').then(({NodeSelection}) => { + const view = window.Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller~="coplan--editor"]'), 'coplan--editor').richEditor.view; + view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, 0))); + done(); + }); + JS + click_button "Insert content", enable_aria_label: true + click_button "Embed", exact: true + expect(page).to have_field("Presentation Markdown", enable_aria_label: true, with: /Existing deck/) + expect(page).to have_css('[data-embed-attribute="src"]:focus') + end + + it "focuses a newly inserted iframe and lets removal be undone" do + source = '::: {.iframe src="https://unapproved.example.com/first" title="First" /}' + "\n\nEnd.\n" + CoPlan::Plans::ReplaceContent.call(plan: plan, new_content: source, + base_revision: plan.current_revision, actor_type: "human", actor_id: author.id) + visit plan_edit_page_path(plan) + editor + find(".ProseMirror > p", text: "End.").click + click_button "Insert content", enable_aria_label: true + click_button "Embed", exact: true + expect(page).to have_css('[data-embed-attribute="src"]', count: 2) + expect(page.evaluate_script('document.activeElement.value')).to eq('https://') + focused = find('[data-embed-attribute="src"]:focus') + focused.fill_in with: 'https://unapproved.example.com/second' + block = focused.find(:xpath, 'ancestor::div[contains(@class,"document-editor__block")]', match: :first) + block.click_button "Remove embedded page", enable_aria_label: true + expect(page).to have_css('[data-embed-attribute="src"]', count: 1) + click_button "Undo", enable_aria_label: true + expect(page).to have_css('[data-embed-attribute="src"]', count: 2) + end + + it "opens back-matter citations for whole content blocks in both themes" do + source = "::: {.presentation}\n\n# Review\n\nDecision.[^catalog]\n\n:::\n\n" + + '::: {.iframe src="https://unapproved.example.com/report" title="Report" /}' + "\n\n[^catalog]: Catalog details.\n" + CoPlan::Plans::ReplaceContent.call(plan: plan, new_content: source, + base_revision: plan.current_revision, actor_type: "human", actor_id: author.id) + visit plan_edit_page_path(plan) + editor + %w[light dark].each do |theme| + page.execute_script('document.documentElement.dataset.theme = arguments[0]', theme) + expect(page).to have_field("Presentation Markdown", enable_aria_label: true) + expect(page).to have_field("Embedded page URL", enable_aria_label: true) + page.save_screenshot(Rails.root.join("tmp/editor-tryout/content-blocks-#{theme}.png")) + click_button "Edit reference catalog", enable_aria_label: true + expect(page).to have_css('dialog[open]', text: 'Citation: catalog') + expect(find('#coplan-citation-body').value).to eq('Catalog details.') + page.save_screenshot(Rails.root.join("tmp/editor-tryout/citation-#{theme}.png")) + click_button "Cancel" + end + end + + it "leaves unclosed or unknown presentation markers visible and code examples literal" do + visit plan_edit_page_path(plan) + editor + fixtures = [ + "::: {.presentation}\n\n# Unclosed\n", + "::: {.unknown}\n\nText\n\n:::\n", + "```markdown\n::: {.presentation}\n\n# Example\n\n:::\n```\n", + "> ::: {.presentation}\n>\n> quoted\n>\n> :::\n" + ] + results = page.evaluate_async_script(<<~'JS', fixtures) + const [fixtures, done] = arguments; + import("coplan/rich_document").then(m => done(fixtures.map(source => { + const doc = m.parseDocument(source); + let boundaries = 0; + doc.forEach(node => { if (node.attrs.kind === "presentation") boundaries++; }); + return { source: m.serializeDocument(doc), boundaries }; + }))); + JS + expect(results).to eq(fixtures.map { |source| { "source" => source, "boundaries" => 0 } }) + end + it "keeps untouched blocks exact when editing beside tables and Mermaid" do visit plan_edit_page_path(plan) editor @@ -280,6 +550,30 @@ def save_now expect(editor).to have_text("First edit plus in-flight typing") end + it "retains the draft across an expired sign-in and refreshes the token on retry" do + visit plan_edit_page_path(plan) + editor + page.execute_script(<<~'JS') + const original = window.fetch; + window.signedOut = true; + const meta = document.querySelector('meta[name="csrf-token"]') || document.head.appendChild(document.createElement('meta')); + meta.name = 'csrf-token'; meta.content = 'expired'; + window.fetch = async (url, options) => { + if (window.signedOut && (url.includes('editor_state') || options?.method === 'PATCH')) return new Response('{}', {status: 401, headers: {'Content-Type': 'application/json'}}); + if (options?.method === 'PATCH' && options.headers['X-CSRF-Token'] === 'expired') return new Response('Old session token', {status: 422, headers: {'Content-Type': 'text/html'}}); + return original(url, options); + }; + JS + editor.send_keys([ RUBY_PLATFORM.include?("darwin") ? :meta : :control, "a" ], "Draft after expired sign-in") + save_now + expect(page).to have_content("Sign in again · draft retained") + expect(editor).to have_text("Draft after expired sign-in") + page.execute_script('window.signedOut = false') + click_button "Retry sync", enable_aria_label: true + expect(page).to have_content("All changes saved · v2") + expect(plan.reload.current_content).to include("Draft after expired sign-in") + end + it "reconciles a committed save whose response was lost without making another version" do visit plan_edit_page_path(plan) editor From be235a0efb2515ac23131c1b982c838f312847d1 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 9 Oct 2026 13:47:20 -0400 Subject: [PATCH 2/3] Recover new drafts and retain live citation controls --- docs/human-document-editor.md | 2 ++ .../controllers/coplan/plans_controller.rb | 2 ++ .../controllers/coplan/editor_controller.js | 21 ++++++++++++---- engine/app/services/coplan/broadcaster.rb | 2 +- .../views/coplan/plans/_editor_form.html.erb | 1 + spec/requests/human_document_editor_spec.rb | 2 ++ spec/services/broadcaster_spec.rb | 9 +++++++ spec/system/editor_recovery_spec.rb | 25 +++++++++++++++++++ 8 files changed, 58 insertions(+), 6 deletions(-) diff --git a/docs/human-document-editor.md b/docs/human-document-editor.md index 306c2484..01cfbf09 100644 --- a/docs/human-document-editor.md +++ b/docs/human-document-editor.md @@ -253,3 +253,5 @@ content. Passing caret tests do not establish that report's cause or resolution. The original open draft was unavailable for inspection and was left untouched. An expired sign-in keeps the draft in the current tab and shows **Sign in again · draft retained**. Sign in in another tab, then retry the original editor. Its uncached editor-state response refreshes the security token before the next save. Citation links use matching labels: `[^catalog]` in the text and `[^catalog]: Source text` in the definitions. Editing the source text in the citation dialog preserves that link; renaming a label in Markdown requires changing both places. + +Unsaved new plans refresh their security token from the uncached new-plan form and retry with the same creation key. Live citation replacements include the same dialog controls as the initial page. diff --git a/engine/app/controllers/coplan/plans_controller.rb b/engine/app/controllers/coplan/plans_controller.rb index 2d41aa88..9617f825 100644 --- a/engine/app/controllers/coplan/plans_controller.rb +++ b/engine/app/controllers/coplan/plans_controller.rb @@ -213,6 +213,8 @@ def update end def new + response.headers["Cache-Control"] = "no-store" + response.headers["X-CSRF-Token"] = form_authenticity_token @new_plan_type = PlanType.find_by(id: params[:plan_type_id]) if params[:plan_type_id].present? return head :not_found if params[:plan_type_id].present? && !@new_plan_type @new_folder = current_user.library.folders.find_by(id: params[:folder_id]) if params[:folder_id].present? diff --git a/engine/app/javascript/controllers/coplan/editor_controller.js b/engine/app/javascript/controllers/coplan/editor_controller.js index a4051d33..79551a12 100644 --- a/engine/app/javascript/controllers/coplan/editor_controller.js +++ b/engine/app/javascript/controllers/coplan/editor_controller.js @@ -6,7 +6,7 @@ import { commandFor } from "coplan/shortcuts" // 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, previewUrl: String, leaseUrl: String, inline: Boolean, embedDomains: Array } + static values = { planId: String, userId: String, draftScope: String, revision: Number, stateUrl: String, tokenUrl: String, previewUrl: String, leaseUrl: String, inline: Boolean, embedDomains: Array } async connect() { this.active = true @@ -113,19 +113,23 @@ export default class extends Controller { } } - async request(url, method = "GET", body) { + async request(url, method = "GET", body, tokenOnly = false) { const abort = new AbortController(), timer = setTimeout(() => abort.abort(), 15000) try { const response = await fetch(url, { method, signal: abort.signal, cache: "no-store", - headers: { "Content-Type": "application/json", Accept: "application/json", "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content }, + headers: { "Content-Type": "application/json", Accept: tokenOnly ? "text/html" : "application/json", "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }) if (response.redirected || response.status === 401) throw Object.assign(new Error("Sign in again in another tab, then retry here. Your draft is retained."), { code: "sign_in_required" }) + const token = response.headers.get("X-CSRF-Token") + if (response.ok && token) document.querySelector('meta[name="csrf-token"]')?.setAttribute("content", token) + if (tokenOnly) { + if (!response.ok || !token) throw new Error("Could not refresh your session. Your draft is retained; retry here after signing in.") + return true + } if (!response.headers.get("content-type")?.includes("application/json")) { if (response.status === 422) throw Object.assign(new Error("Your session changed. Sign in again in another tab, then retry here. Your draft is retained."), { code: "sign_in_required" }) throw new Error("Could not sync. Check your connection or sign in again; your draft is retained.") } - const token = response.headers.get("X-CSRF-Token") - if (response.ok && token) document.querySelector('meta[name="csrf-token"]')?.setAttribute("content", token) const data = await response.json() if (!response.ok) throw Object.assign(new Error(data.error || "Could not save"), data, { status: response.status }) return data @@ -319,6 +323,13 @@ export default class extends Controller { if (!this.active) return this.retryable = false if (this.inlineValue) this.setStatus("Retrying sync…", "saving") + if (this.isNew) { + try { + await this.request(this.tokenUrlValue, "GET", undefined, true) + if (this.active) await this.flush(true) + } catch (error) { this.fail(error.message, error.code) } + return + } if (!await this.refresh() || !this.active || this.blocked) return if (this.dirty()) await this.flush(true) else this.setStatus(`All changes saved · v${this.base.revision}`, "saved") diff --git a/engine/app/services/coplan/broadcaster.rb b/engine/app/services/coplan/broadcaster.rb index 8ee3d2ff..2a465066 100644 --- a/engine/app/services/coplan/broadcaster.rb +++ b/engine/app/services/coplan/broadcaster.rb @@ -107,7 +107,7 @@ def replace_plan_references(plan) plan, action: "coplan-replace-if-clean", target: "plan-citations", - html: back_matter[:html], + html: CoPlan::ApplicationController.helpers.citation_edit_controls(back_matter[:html]), attrs: { "data-revision" => plan.current_revision } ) custom_action_to( diff --git a/engine/app/views/coplan/plans/_editor_form.html.erb b/engine/app/views/coplan/plans/_editor_form.html.erb index 28eb44e5..ce3df05b 100644 --- a/engine/app/views/coplan/plans/_editor_form.html.erb +++ b/engine/app/views/coplan/plans/_editor_form.html.erb @@ -7,6 +7,7 @@ "coplan--editor-draft-scope-value": (creating ? "#{@new_plan_type&.id || 'general'}-#{@new_folder&.id || 'root'}" : ""), "coplan--editor-revision-value": @base_revision, "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-embed-domains-value": CoPlan::EmbedDomain.order(:hostname).pluck(:hostname).to_json, "coplan--editor-preview-url-value": preview_draft_plans_path } do %> diff --git a/spec/requests/human_document_editor_spec.rb b/spec/requests/human_document_editor_spec.rb index 0e17a4d2..9ca25821 100644 --- a/spec/requests/human_document_editor_spec.rb +++ b/spec/requests/human_document_editor_spec.rb @@ -13,6 +13,8 @@ def acquire it "provides a human-first rich editor and creates a private human version" do get new_plan_path + expect(response.headers["X-CSRF-Token"]).to be_present + expect(response.headers["Cache-Control"]).to include("no-store") expect(response).to have_http_status(:ok) expect(response.body).to include('aria-label="Text formatting"', 'data-command="bold"') post plans_path, params: { plan: { title: "Human document" }, content: "My **draft**" }, as: :json diff --git a/spec/services/broadcaster_spec.rb b/spec/services/broadcaster_spec.rb index 2d89fbbd..79788c2c 100644 --- a/spec/services/broadcaster_spec.rb +++ b/spec/services/broadcaster_spec.rb @@ -85,6 +85,15 @@ describe ".replace_plan_references" do before { plan } + it "preserves citation edit controls in live replacements" do + allow(CoPlan::ApplicationController.helpers).to receive(:plan_citation_back_matter).and_return( + { html: '
  1. Catalog source.

', cited_urls: [], count: 1 }) + payloads = [] + allow(Turbo::StreamsChannel).to receive(:broadcast_stream_to) { |_streamable, content:| payloads << content.to_s } + described_class.replace_plan_references(plan) + expect(payloads.first).to include('data-citation-label="catalog"', 'coplan--inline-editor#editCitation') + end + it "broadcasts unified back matter after extracted references exist" do payloads = [] allow(Turbo::StreamsChannel).to receive(:broadcast_stream_to) do |_streamable, content:| diff --git a/spec/system/editor_recovery_spec.rb b/spec/system/editor_recovery_spec.rb index 97a4cbbc..70ef3939 100644 --- a/spec/system/editor_recovery_spec.rb +++ b/spec/system/editor_recovery_spec.rb @@ -89,6 +89,31 @@ def rich expect(plan.reload.title).to eq("Recovery") end + it "recovers an unsaved new plan after signing in again without reloading" do + visit new_plan_path + rich + page.execute_script(<<~'JS') + const original = window.fetch; + const meta = document.querySelector('meta[name="csrf-token"]') || document.head.appendChild(document.createElement('meta')); + meta.name = 'csrf-token'; meta.content = 'expired'; + window.signedOut = true; + window.fetch = async (url, options) => { + if (window.signedOut && options?.method === 'POST' && JSON.parse(options.body || '{}').creation_key) return new Response('{}', {status: 401, headers: {'Content-Type': 'application/json'}}); + if (options?.method === 'POST' && JSON.parse(options.body || '{}').creation_key && options.headers['X-CSRF-Token'] === 'expired') return new Response('Old token', {status: 422, headers: {'Content-Type': 'text/html'}}); + return original(url, options); + }; + JS + find("#plan-header .inline-editor__title").send_keys("Recovered new plan") + rich.send_keys("Retained creation draft") + expect(page).to have_content("Sign in again · draft retained", wait: 10) + expect(author.created_plans.where(title: "Recovered new plan")).to be_empty + page.execute_script('window.signedOut = false') + click_button "Retry sync", enable_aria_label: true + expect(page).to have_content("All changes saved · v1", wait: 15) + expect(author.created_plans.where(title: "Recovered new plan").count).to eq(1) + expect(author.created_plans.find_by!(title: "Recovered new plan").current_content).to include("Retained creation draft") + end + it "retries a lost creation response after reload without duplication or losing newer typing" do visit new_plan_path rich From 300a7bdef564de133ceea3de7532122d6b44f7c1 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 9 Oct 2026 14:17:23 -0400 Subject: [PATCH 3/3] Match citation labels and refresh updated presentation previews --- docs/human-document-editor.md | 2 + .../controllers/coplan/editor_controller.js | 10 ++-- .../app/javascript/coplan/citation_labels.js | 7 +++ engine/app/javascript/coplan/rich_document.js | 28 ++++++---- engine/config/importmap.rb | 1 + spec/system/human_editing_spec.rb | 54 +++++++++++++++++++ 6 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 engine/app/javascript/coplan/citation_labels.js diff --git a/docs/human-document-editor.md b/docs/human-document-editor.md index 01cfbf09..f40eff33 100644 --- a/docs/human-document-editor.md +++ b/docs/human-document-editor.md @@ -255,3 +255,5 @@ The original open draft was unavailable for inspection and was left untouched. An expired sign-in keeps the draft in the current tab and shows **Sign in again · draft retained**. Sign in in another tab, then retry the original editor. Its uncached editor-state response refreshes the security token before the next save. Citation links use matching labels: `[^catalog]` in the text and `[^catalog]: Source text` in the definitions. Editing the source text in the citation dialog preserves that link; renaming a label in Markdown requires changing both places. Unsaved new plans refresh their security token from the uncached new-plan form and retry with the same creation key. Live citation replacements include the same dialog controls as the initial page. + +Citation labels match using Unicode case folding and collapsed whitespace; dialog edits preserve the original spelling of both the marker and definition. An open presentation preview refreshes when Undo, Redo, or a live update changes its source, and ignores older preview responses. diff --git a/engine/app/javascript/controllers/coplan/editor_controller.js b/engine/app/javascript/controllers/coplan/editor_controller.js index 79551a12..5c49df36 100644 --- a/engine/app/javascript/controllers/coplan/editor_controller.js +++ b/engine/app/javascript/controllers/coplan/editor_controller.js @@ -740,7 +740,7 @@ export default class extends Controller { event.preventDefault(); event.stopPropagation() this.citationTrigger = event.currentTarget this.citationLabel = event.currentTarget.dataset.citationLabel - const definition = this.richModule.citationDefinitions(this.textareaTarget.value).find(d => d.label === this.citationLabel) + const definition = this.findCitationDefinition(this.textareaTarget.value) this.citationOriginal = definition?.source || null this.citationTitleTarget.textContent = `Citation: ${this.citationLabel}` this.citationBodyTarget.value = definition?.body || "" @@ -752,17 +752,21 @@ export default class extends Controller { this.citationDialogTarget.close() if (this.citationTrigger?.isConnected) this.citationTrigger.focus({ preventScroll: true }) } + findCitationDefinition(content) { + const key = this.richModule.citationLabelKey(this.citationLabel) + return this.richModule.citationDefinitions(content).find(d => this.richModule.citationLabelKey(d.label) === key) + } async saveCitation() { const body = this.citationBodyTarget.value.trim() if (!body) { this.citationErrorTarget.textContent = "Add the citation text or source link."; this.citationBodyTarget.focus(); return } const content = this.textareaTarget.value - const definition = this.richModule.citationDefinitions(content).find(d => d.label === this.citationLabel) + const definition = this.findCitationDefinition(content) if ((definition?.source || null) !== this.citationOriginal) { this.citationErrorTarget.textContent = "This citation changed while you were editing. Close and reopen it to review the new text." return } const lines = body.split("\n") - const source = `[^${this.citationLabel}]: ${lines[0]}\n` + lines.slice(1).map(line => ` ${line}\n`).join("") + const source = `[^${definition?.label ?? this.citationLabel}]: ${lines[0]}\n` + lines.slice(1).map(line => ` ${line}\n`).join("") const from = definition?.from ?? content.length, to = definition?.to ?? content.length this.richEditor.replaceSource(from, to, definition ? source : `${content.endsWith("\n\n") ? "" : "\n\n"}${source}`) this.citationOriginal = source diff --git a/engine/app/javascript/coplan/citation_labels.js b/engine/app/javascript/coplan/citation_labels.js new file mode 100644 index 00000000..81cde4bc --- /dev/null +++ b/engine/app/javascript/coplan/citation_labels.js @@ -0,0 +1,7 @@ +// Unicode full case folding, matching Ruby downcase(:fold) in Slideshows::Split. +// Only mappings that differ from ordinary lowercase need this table. +const folds = {"µ":"μ","ß":"ss","ʼn":"ʼn","ſ":"s","ǰ":"ǰ","ͅ":"ι","ΐ":"ΐ","ΰ":"ΰ","ς":"σ","ϐ":"β","ϑ":"θ","ϕ":"φ","ϖ":"π","ϰ":"κ","ϱ":"ρ","ϵ":"ε","և":"եւ","Ꭰ":"Ꭰ","Ꭱ":"Ꭱ","Ꭲ":"Ꭲ","Ꭳ":"Ꭳ","Ꭴ":"Ꭴ","Ꭵ":"Ꭵ","Ꭶ":"Ꭶ","Ꭷ":"Ꭷ","Ꭸ":"Ꭸ","Ꭹ":"Ꭹ","Ꭺ":"Ꭺ","Ꭻ":"Ꭻ","Ꭼ":"Ꭼ","Ꭽ":"Ꭽ","Ꭾ":"Ꭾ","Ꭿ":"Ꭿ","Ꮀ":"Ꮀ","Ꮁ":"Ꮁ","Ꮂ":"Ꮂ","Ꮃ":"Ꮃ","Ꮄ":"Ꮄ","Ꮅ":"Ꮅ","Ꮆ":"Ꮆ","Ꮇ":"Ꮇ","Ꮈ":"Ꮈ","Ꮉ":"Ꮉ","Ꮊ":"Ꮊ","Ꮋ":"Ꮋ","Ꮌ":"Ꮌ","Ꮍ":"Ꮍ","Ꮎ":"Ꮎ","Ꮏ":"Ꮏ","Ꮐ":"Ꮐ","Ꮑ":"Ꮑ","Ꮒ":"Ꮒ","Ꮓ":"Ꮓ","Ꮔ":"Ꮔ","Ꮕ":"Ꮕ","Ꮖ":"Ꮖ","Ꮗ":"Ꮗ","Ꮘ":"Ꮘ","Ꮙ":"Ꮙ","Ꮚ":"Ꮚ","Ꮛ":"Ꮛ","Ꮜ":"Ꮜ","Ꮝ":"Ꮝ","Ꮞ":"Ꮞ","Ꮟ":"Ꮟ","Ꮠ":"Ꮠ","Ꮡ":"Ꮡ","Ꮢ":"Ꮢ","Ꮣ":"Ꮣ","Ꮤ":"Ꮤ","Ꮥ":"Ꮥ","Ꮦ":"Ꮦ","Ꮧ":"Ꮧ","Ꮨ":"Ꮨ","Ꮩ":"Ꮩ","Ꮪ":"Ꮪ","Ꮫ":"Ꮫ","Ꮬ":"Ꮬ","Ꮭ":"Ꮭ","Ꮮ":"Ꮮ","Ꮯ":"Ꮯ","Ꮰ":"Ꮰ","Ꮱ":"Ꮱ","Ꮲ":"Ꮲ","Ꮳ":"Ꮳ","Ꮴ":"Ꮴ","Ꮵ":"Ꮵ","Ꮶ":"Ꮶ","Ꮷ":"Ꮷ","Ꮸ":"Ꮸ","Ꮹ":"Ꮹ","Ꮺ":"Ꮺ","Ꮻ":"Ꮻ","Ꮼ":"Ꮼ","Ꮽ":"Ꮽ","Ꮾ":"Ꮾ","Ꮿ":"Ꮿ","Ᏸ":"Ᏸ","Ᏹ":"Ᏹ","Ᏺ":"Ᏺ","Ᏻ":"Ᏻ","Ᏼ":"Ᏼ","Ᏽ":"Ᏽ","ᏸ":"Ᏸ","ᏹ":"Ᏹ","ᏺ":"Ᏺ","ᏻ":"Ᏻ","ᏼ":"Ᏼ","ᏽ":"Ᏽ","ᲀ":"в","ᲁ":"д","ᲂ":"о","ᲃ":"с","ᲄ":"т","ᲅ":"т","ᲆ":"ъ","ᲇ":"ѣ","ᲈ":"ꙋ","ẖ":"ẖ","ẗ":"ẗ","ẘ":"ẘ","ẙ":"ẙ","ẚ":"aʾ","ẛ":"ṡ","ẞ":"ss","ὐ":"ὐ","ὒ":"ὒ","ὔ":"ὔ","ὖ":"ὖ","ᾀ":"ἀι","ᾁ":"ἁι","ᾂ":"ἂι","ᾃ":"ἃι","ᾄ":"ἄι","ᾅ":"ἅι","ᾆ":"ἆι","ᾇ":"ἇι","ᾈ":"ἀι","ᾉ":"ἁι","ᾊ":"ἂι","ᾋ":"ἃι","ᾌ":"ἄι","ᾍ":"ἅι","ᾎ":"ἆι","ᾏ":"ἇι","ᾐ":"ἠι","ᾑ":"ἡι","ᾒ":"ἢι","ᾓ":"ἣι","ᾔ":"ἤι","ᾕ":"ἥι","ᾖ":"ἦι","ᾗ":"ἧι","ᾘ":"ἠι","ᾙ":"ἡι","ᾚ":"ἢι","ᾛ":"ἣι","ᾜ":"ἤι","ᾝ":"ἥι","ᾞ":"ἦι","ᾟ":"ἧι","ᾠ":"ὠι","ᾡ":"ὡι","ᾢ":"ὢι","ᾣ":"ὣι","ᾤ":"ὤι","ᾥ":"ὥι","ᾦ":"ὦι","ᾧ":"ὧι","ᾨ":"ὠι","ᾩ":"ὡι","ᾪ":"ὢι","ᾫ":"ὣι","ᾬ":"ὤι","ᾭ":"ὥι","ᾮ":"ὦι","ᾯ":"ὧι","ᾲ":"ὰι","ᾳ":"αι","ᾴ":"άι","ᾶ":"ᾶ","ᾷ":"ᾶι","ᾼ":"αι","ι":"ι","ῂ":"ὴι","ῃ":"ηι","ῄ":"ήι","ῆ":"ῆ","ῇ":"ῆι","ῌ":"ηι","ῒ":"ῒ","ΐ":"ΐ","ῖ":"ῖ","ῗ":"ῗ","ῢ":"ῢ","ΰ":"ΰ","ῤ":"ῤ","ῦ":"ῦ","ῧ":"ῧ","ῲ":"ὼι","ῳ":"ωι","ῴ":"ώι","ῶ":"ῶ","ῷ":"ῶι","ῼ":"ωι","ꭰ":"Ꭰ","ꭱ":"Ꭱ","ꭲ":"Ꭲ","ꭳ":"Ꭳ","ꭴ":"Ꭴ","ꭵ":"Ꭵ","ꭶ":"Ꭶ","ꭷ":"Ꭷ","ꭸ":"Ꭸ","ꭹ":"Ꭹ","ꭺ":"Ꭺ","ꭻ":"Ꭻ","ꭼ":"Ꭼ","ꭽ":"Ꭽ","ꭾ":"Ꭾ","ꭿ":"Ꭿ","ꮀ":"Ꮀ","ꮁ":"Ꮁ","ꮂ":"Ꮂ","ꮃ":"Ꮃ","ꮄ":"Ꮄ","ꮅ":"Ꮅ","ꮆ":"Ꮆ","ꮇ":"Ꮇ","ꮈ":"Ꮈ","ꮉ":"Ꮉ","ꮊ":"Ꮊ","ꮋ":"Ꮋ","ꮌ":"Ꮌ","ꮍ":"Ꮍ","ꮎ":"Ꮎ","ꮏ":"Ꮏ","ꮐ":"Ꮐ","ꮑ":"Ꮑ","ꮒ":"Ꮒ","ꮓ":"Ꮓ","ꮔ":"Ꮔ","ꮕ":"Ꮕ","ꮖ":"Ꮖ","ꮗ":"Ꮗ","ꮘ":"Ꮘ","ꮙ":"Ꮙ","ꮚ":"Ꮚ","ꮛ":"Ꮛ","ꮜ":"Ꮜ","ꮝ":"Ꮝ","ꮞ":"Ꮞ","ꮟ":"Ꮟ","ꮠ":"Ꮠ","ꮡ":"Ꮡ","ꮢ":"Ꮢ","ꮣ":"Ꮣ","ꮤ":"Ꮤ","ꮥ":"Ꮥ","ꮦ":"Ꮦ","ꮧ":"Ꮧ","ꮨ":"Ꮨ","ꮩ":"Ꮩ","ꮪ":"Ꮪ","ꮫ":"Ꮫ","ꮬ":"Ꮬ","ꮭ":"Ꮭ","ꮮ":"Ꮮ","ꮯ":"Ꮯ","ꮰ":"Ꮰ","ꮱ":"Ꮱ","ꮲ":"Ꮲ","ꮳ":"Ꮳ","ꮴ":"Ꮴ","ꮵ":"Ꮵ","ꮶ":"Ꮶ","ꮷ":"Ꮷ","ꮸ":"Ꮸ","ꮹ":"Ꮹ","ꮺ":"Ꮺ","ꮻ":"Ꮻ","ꮼ":"Ꮼ","ꮽ":"Ꮽ","ꮾ":"Ꮾ","ꮿ":"Ꮿ","ff":"ff","fi":"fi","fl":"fl","ffi":"ffi","ffl":"ffl","ſt":"st","st":"st","ﬓ":"մն","ﬔ":"մե","ﬕ":"մի","ﬖ":"վն","ﬗ":"մխ"} + +export function citationLabelKey(label) { + return Array.from(label.trim().replace(/\s+/g, " ")).map(character => folds[character] || character.toLowerCase()).join("") +} diff --git a/engine/app/javascript/coplan/rich_document.js b/engine/app/javascript/coplan/rich_document.js index ccf0b7e8..04f38ad4 100644 --- a/engine/app/javascript/coplan/rich_document.js +++ b/engine/app/javascript/coplan/rich_document.js @@ -1,6 +1,8 @@ import { diffArrays } from "diff" import { codeHighlight } from "coplan/code_highlight" import { textHunks } from "coplan/merge_text" +import { citationLabelKey } from "coplan/citation_labels" +export { citationLabelKey } from "coplan/citation_labels" import { Schema, Fragment, Slice } from "prosemirror-model" import { EditorState, TextSelection, Plugin, PluginKey } from "prosemirror-state" import { EditorView, Decoration, DecorationSet } from "prosemirror-view" @@ -36,8 +38,8 @@ const schema = new Schema({ nodes, marks: defaultMarkdownParser.schema.spec.mark // content, so it must not turn its entire paragraph or list into a source card. const tokenizer = defaultMarkdownParser.tokenizer tokenizer.inline.ruler.before("link", "coplan_footnote", (state, silent) => { - const match = /^\[\^([^\]\s]+)\]/.exec(state.src.slice(state.pos)) - if (!match) return false + const match = /^\[\^([^\]]+)\]/.exec(state.src.slice(state.pos)) + if (!match || !citationLabelKey(match[1])) return false if (!silent) state.push("footnote_reference", "", 0).meta = { label: match[1] } state.pos += match[0].length return true @@ -101,8 +103,8 @@ function contentRanges(tokens, lines, offsets, markdown, definitionsOnly = false const excluded = ranges.filter(t => ["fence", "code_block", "html_block", "blockquote_open", "bullet_list_open", "ordered_list_open"].includes(t.type)) for (let line = 0; line < lines.length; line++) { if (excluded.some(t => line >= t.map[0] && line < t.map[1])) continue - const match = /^ {0,3}\[\^([^\]\s]+)\]:[ \t]*(.*)$/.exec(lines[line]) - if (!match || (!definitionsOnly && specials.some(r => offsets[line] >= r.from && offsets[line] < r.to))) continue + const match = /^ {0,3}\[\^([^\]]+)\]:[ \t]*(.*)$/.exec(lines[line]) + if (!match || !citationLabelKey(match[1]) || (!definitionsOnly && specials.some(r => offsets[line] >= r.from && offsets[line] < r.to))) continue let end = line + 1 while (end < lines.length) { if (/^(?: {4}|\t)\S?/.test(lines[end]) && lines[end].trim()) { end++; continue } @@ -779,13 +781,8 @@ function sourceNodeView(initial, view, getPos, preview, sourceRangeAt) { input.dataset.action = "input->coplan--editor#blockSourceChanged keydown->coplan--editor#blockKeydown" input.value = presentationContent(node.attrs.source).content dom.append(input, body) - dom.coplanTogglePreview = async () => { - const showing = body.hidden - toggle.setAttribute("aria-expanded", String(showing)) - toggle.textContent = showing ? "Edit Markdown" : "Preview" - input.hidden = showing; body.hidden = !showing + const renderPreview = async () => { const sequence = ++generation - if (!showing) { input.focus({ preventScroll: true }); return } body.textContent = "Loading preview…"; body.setAttribute("aria-busy", "true") try { const definitions = [] @@ -800,8 +797,18 @@ function sourceNodeView(initial, view, getPos, preview, sourceRangeAt) { if (sequence === generation) body.removeAttribute("aria-busy") } } + dom.coplanTogglePreview = () => { + const showing = body.hidden + toggle.setAttribute("aria-expanded", String(showing)) + toggle.textContent = showing ? "Edit Markdown" : "Preview" + input.hidden = showing; body.hidden = !showing + if (showing) return renderPreview() + generation++; body.removeAttribute("aria-busy") + input.focus({ preventScroll: true }) + } return { dom, update(next) { if (next.type !== node.type || next.attrs.kind !== "presentation") return false + const changed = next.attrs.source !== node.attrs.source node = next const content = presentationContent(node.attrs.source).content if (input.value !== content) { @@ -809,6 +816,7 @@ function sourceNodeView(initial, view, getPos, preview, sourceRangeAt) { input.value = content input.setSelectionRange(Math.min(start, content.length), Math.min(end, content.length)) } + if (changed && !body.hidden) renderPreview() return true }, stopEvent: () => true, ignoreMutation: () => true, destroy() { generation++ } } } diff --git a/engine/config/importmap.rb b/engine/config/importmap.rb index 8a1d2d4c..220362bc 100644 --- a/engine/config/importmap.rb +++ b/engine/config/importmap.rb @@ -19,6 +19,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/citation_labels", to: "coplan/citation_labels.js", preload: false pin "coplan/viewport_anchor", to: "coplan/viewport_anchor.js", preload: false pin "coplan/content_sections", to: "coplan/content_sections.js", preload: false pin "coplan/deck_state", to: "coplan/deck_state.js", preload: false diff --git a/spec/system/human_editing_spec.rb b/spec/system/human_editing_spec.rb index b8fc945e..75fe4a56 100644 --- a/spec/system/human_editing_spec.rb +++ b/spec/system/human_editing_spec.rb @@ -173,6 +173,60 @@ def save_now "citation" => "Cited prose.[^catalog]", "plain" => "Cited prose.[^catalog]") end + it "refreshes a visible presentation preview and discards older responses" do + visit plan_edit_page_path(plan) + editor + result = page.evaluate_async_script(<<~'JS') + const done = arguments[0]; + import("coplan/rich_document").then(async m => { + const host = document.createElement("div"), requests = []; + const source = title => `::: {.presentation}\n\n# ${title}\n\n:::\n`; + const rich = m.createRichDocument(host, source("Original"), () => {}, () => {}, markdown => + new Promise(resolve => requests.push({ markdown, resolve }))); + const block = host.querySelector('.document-editor__content-block'); + const first = block.coplanTogglePreview(); + rich.update(source("Updated")); + requests[1].resolve('

Updated preview

'); + await Promise.resolve(); + requests[0].resolve('

Original preview

'); + await first; + const visible = host.querySelector('.document-editor__block-preview').textContent; + const markdown = requests.map(request => request.markdown); + rich.destroy(); + done({ visible, markdown }); + }).catch(error => done({ error: error.message })); + JS + expect(result.fetch("visible")).to eq("Updated preview") + expect(result.fetch("markdown")).to eq([ + "::: {.presentation}\n\n# Original\n\n:::\n", "::: {.presentation}\n\n# Updated\n\n:::\n" + ]) + end + + it "matches folded and whitespace citation labels while retaining their source spelling" do + source = "Road.[^STRASSE] Context.[^ Catalog shape ]\n\n[^straße]: Street details.\n[^catalog shape]: Shape details.\n" + CoPlan::Plans::ReplaceContent.call(plan: plan, new_content: source, + base_revision: plan.current_revision, actor_type: "human", actor_id: author.id) + visit plan_edit_page_path(plan) + editor + click_button "Edit citation STRASSE", enable_aria_label: true + expect(find('#coplan-citation-body').value).to eq("Street details.") + fill_in "Citation text and source links", with: "Updated street." + click_button "Save citation" + expect(page).to have_no_css('dialog[open]') + find('button[data-citation-label=" Catalog shape "]').click + expect(find('#coplan-citation-body').value).to eq("Shape details.") + fill_in "Citation text and source links", with: "Updated shape." + click_button "Save citation" + expect(page).to have_no_css('dialog[open]') + expect(plan.reload.current_content).to eq(source.sub("Street details.", "Updated street.").sub("Shape details.", "Updated shape.")) + pairs = [ [ "straße", "STRASSE" ], [ "ς", "Σ" ], [ "ffi", "FFI" ], [ "Ꭰ", "ꭰ" ], [ "ı", "I" ] ] + result = page.evaluate_async_script(<<~'JS', pairs) + const pairs = arguments[0], done = arguments[1]; + import("coplan/rich_document").then(m => done(pairs.map(([a, b]) => m.citationLabelKey(a) === m.citationLabelKey(b)))); + JS + expect(result).to eq(pairs.map { |a, b| a.downcase(:fold) == b.downcase(:fold) }) + end + it "edits citations in a dialog while keeping definitions out of the body" do source = "A catalog price.[^catalog] More context.[^other]\n\n[^catalog]: Catalog details.\n A second line.\n\n A second paragraph.\n[^other]: Keep this definition.\n" CoPlan::Plans::ReplaceContent.call(plan: plan, new_content: source,