diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 103b67d..fd0bb4d 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -707,6 +707,26 @@ img.avatar { line-height: 1.9; } +.markdown-rendered .task-list { + list-style: none; + padding-left: var(--space-md); +} + +.markdown-rendered .task-list-item label { + display: flex; + align-items: baseline; + gap: var(--space-sm); + cursor: pointer; +} + +.markdown-rendered .task-list-item input[type="checkbox"] { + cursor: pointer; + flex-shrink: 0; + margin: 0; + position: relative; + top: 1px; +} + .markdown-rendered pre { background: #f6f8fa; border: 1px solid var(--color-border); diff --git a/engine/app/controllers/coplan/plans_controller.rb b/engine/app/controllers/coplan/plans_controller.rb index d600399..1fb7b1d 100644 --- a/engine/app/controllers/coplan/plans_controller.rb +++ b/engine/app/controllers/coplan/plans_controller.rb @@ -1,6 +1,6 @@ module CoPlan class PlansController < ApplicationController - before_action :set_plan, only: [:show, :edit, :update, :update_status] + before_action :set_plan, only: [:show, :edit, :update, :update_status, :toggle_checkbox] def index @plans = Plan.includes(:plan_type, :tags).order(updated_at: :desc) @@ -51,6 +51,64 @@ def update_status end end + def toggle_checkbox + authorize!(@plan, :show?) + + old_text = params[:old_text] + new_text = params[:new_text] + base_revision = params[:base_revision]&.to_i + + unless old_text.present? && new_text.present? && base_revision.present? + render json: { error: "old_text, new_text, and base_revision are required" }, status: :unprocessable_content + return + end + + checkbox_pattern = /\A\s*[*+-]\s+\[[ xX]\]\s/ + unless old_text.match?(checkbox_pattern) && new_text.match?(checkbox_pattern) + render json: { error: "old_text and new_text must be task list items" }, status: :unprocessable_content + return + end + + ActiveRecord::Base.transaction do + @plan.lock! + @plan.reload + + if @plan.current_revision != base_revision + render json: { error: "Conflict", current_revision: @plan.current_revision }, status: :conflict + return + end + + current_content = @plan.current_content || "" + result = Plans::ApplyOperations.call( + content: current_content, + operations: [{ "op" => "replace_exact", "old_text" => old_text, "new_text" => new_text }] + ) + + new_revision = @plan.current_revision + 1 + diff = Diffy::Diff.new(current_content, result[:content]).to_s + + version = PlanVersion.create!( + plan: @plan, + revision: new_revision, + content_markdown: result[:content], + actor_type: "human", + actor_id: current_user.id, + change_summary: "Toggle checkbox", + diff_unified: diff.presence, + operations_json: result[:applied], + base_revision: base_revision + ) + + @plan.update!(current_plan_version: version, current_revision: new_revision) + @plan.comment_threads.mark_out_of_date_for_new_version!(version) + end + + broadcast_plan_update(@plan) + render json: { revision: @plan.current_revision } + rescue Plans::OperationError => e + render json: { error: e.message }, status: :unprocessable_content + end + private def set_plan diff --git a/engine/app/helpers/coplan/markdown_helper.rb b/engine/app/helpers/coplan/markdown_helper.rb index 618556b..5b92cd1 100644 --- a/engine/app/helpers/coplan/markdown_helper.rb +++ b/engine/app/helpers/coplan/markdown_helper.rb @@ -6,7 +6,7 @@ module MarkdownHelper ul ol li table thead tbody tfoot tr th td pre code - a img + a img input label strong em b i u s del blockquote hr br dd dt dl @@ -14,12 +14,13 @@ module MarkdownHelper details summary ].freeze - ALLOWED_ATTRIBUTES = %w[id class href src alt title].freeze + ALLOWED_ATTRIBUTES = %w[id class href src alt title type checked disabled data-line-text data-action data-coplan--checkbox-target].freeze def render_markdown(content) html = Commonmarker.to_html(content.to_s.encode("UTF-8"), options: { render: { unsafe: true } }, plugins: { syntax_highlighter: nil }) sanitized = sanitize(html, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES) - tag.div(sanitized, class: "markdown-rendered") + interactive = make_checkboxes_interactive(sanitized, content) + tag.div(interactive.html_safe, class: "markdown-rendered") end def markdown_to_plain_text(content) @@ -38,5 +39,54 @@ def render_line_view(content) tag.div(safe_join(line_divs), class: "line-view", data: { controller: "line-selection" }) end + + private + + def make_checkboxes_interactive(html, content) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + checkboxes = doc.css('input[type="checkbox"]') + return html if checkboxes.empty? + + task_lines = extract_task_lines(content) + + checkboxes.each_with_index do |cb, i| + line_text = task_lines[i] + next unless line_text + + cb.remove_attribute("disabled") + cb["data-action"] = "coplan--checkbox#toggle" + cb["data-coplan--checkbox-target"] = "checkbox" + cb["data-line-text"] = line_text + + li = cb.parent + next unless li&.name == "li" + li.add_class("task-list-item") + + # Wrap li contents in a