diff --git a/Project.yml b/Project.yml index b4455b6..de6533a 100644 --- a/Project.yml +++ b/Project.yml @@ -161,6 +161,9 @@ targets: - path: Sources/Intents/PostDraftEngine.swift - path: Sources/Compose/ComposeFormatToolbar.swift - path: Sources/Compose/ComposeTypography.swift + - path: Sources/Compose/ComposeBlockMap.swift + - path: Sources/Compose/ComposeMarkupOp.swift + - path: Sources/Compose/ComposeVisualDocument.swift settings: base: MACOSX_DEPLOYMENT_TARGET: "26.0" diff --git a/Sources/Compose/ComposeBlockMap.swift b/Sources/Compose/ComposeBlockMap.swift new file mode 100644 index 0000000..480ede4 --- /dev/null +++ b/Sources/Compose/ComposeBlockMap.swift @@ -0,0 +1,389 @@ +import Foundation + +/// The conservative block map behind visual editing (WYSIWYG-DESIGN.md): +/// splits the buffer into block records by line shape and marker prefixes — +/// the same sniffing posture as `ComposeHighlighter`, never a parse. The map +/// answers two questions: which source lines form each block, and which of +/// those blocks may be visually edited. Alignment with the rendered DOM is +/// *verified* by `alignmentMatches(renderedBlockCount:)`; when it fails the +/// visual surface disables itself rather than guessing. +struct ComposeBlockMap: Equatable, Sendable { + /// Classification of one source block. Mirrors the shapes the + /// highlighter paints; `opaque` is the never-guess bucket. + enum Kind: Equatable, Sendable { + case frontmatter + case heading(level: Int) + case paragraph + case list(ordered: Bool) + case quote + case fence + case code + case rule + case setextHeading(level: Int) + case opaque + } + + /// One block: the source line range (UTF-16 line indices) and its kind. + /// `firstLineUTF16` is the buffer offset of the block's first character + /// — the anchor every buffer splice translates through. + struct Block: Equatable, Sendable { + let kind: Kind + /// 0-based indices into the buffer's line array. + let firstLine: Int + let lastLine: Int + /// UTF-16 offset of the block's first character in the buffer. + let firstLineUTF16: Int + /// The verbatim source text of the block's lines (no trailing + /// newline on the last line). + let text: String + + /// True when this block may carry a `contenteditable` surface. + /// Paragraphs only, and only when `ComposeBlockMap.isEditableText` + /// accepts the line (single line, marker-free). + var isEditableParagraph: Bool { + kind == .paragraph && ComposeBlockMap.isEditableText(text) + } + } + + let blocks: [Block] + + /// Whether the source carried a frontmatter block (affects the + /// rendered-block count when the policy strips it). + let hasFrontmatter: Bool + + /// Characters whose presence anywhere in a line disqualifies visual + /// editing for that line: every inline-marker possibility in Oliver's + /// documented Markdown surface. Deliberately over-strict — a paragraph + /// with an asterisk is simply not visual-editable yet. + static let markerCharacters: Set = [ + "*", "_", "`", "~", "[", "]", "<", ">", "&", "\\", "{", + ] + + /// The editable-paragraph predicate (WYSIWYG-DESIGN.md): exactly one + /// line, and none of the marker characters anywhere in it. + static func isEditableText(_ text: String) -> Bool { + guard !text.isEmpty else { return false } + guard !text.contains("\n") else { return false } + guard !text.contains(where: { markerCharacters.contains($0) }) else { return false } + return true + } + + /// Text-preserving render options: the extensions that rewrite + /// characters or block structure must be off. Mirrors the defaults — + /// anything the preview-options popover turns on empties the editable + /// set instead of corrupting offsets. + static func textPreserving(_ options: MarkupRenderOptions) -> Bool { + !options.smartypants + && !options.wikilinks + && !options.callouts + && !options.footnotes + && !options.definitionLists + && !options.headingAttributes + && !options.strikethrough + && !options.headingIDs + && !options.taskLists + && options.rawHTML == .allowed + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + static func compute(source: String, frontmatterStripped: Bool) -> ComposeBlockMap { + // Computes the map over a buffer (frontmatter already policy-decided: + // pass `frontmatterStripped: false` when the render policy is `none`, + // `true` when Oliver strips it — the map then skips the block). + let lines = source.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + var blocks: [Block] = [] + var hasFrontmatter = false + + var index = 0 + var lineStarts = [Int]() // UTF-16 offset of each line's first character + var offset = 0 + for line in lines { + lineStarts.append(offset) + offset += line.utf16.count + 1 // +1 newline + } + + func append(_ kind: Kind, first: Int, last: Int) { + let text = lines[first...last].joined(separator: "\n") + blocks.append( + Block( + kind: kind, + firstLine: first, + lastLine: last, + firstLineUTF16: lineStarts[first], + text: text + ) + ) + } + + // Frontmatter: `---`/`+++` at line 0 through the closing fence. + if let first = lines.first { + let trimmed = first.trimmingCharacters(in: .whitespaces) + if trimmed == "---" || trimmed == "+++" { + var closeIndex: Int? + var scan = 1 + while scan < lines.count { + if lines[scan].trimmingCharacters(in: .whitespaces) == trimmed { + closeIndex = scan + break + } + scan += 1 + } + if let closeIndex { + hasFrontmatter = true + if !frontmatterStripped { + append(.frontmatter, first: 0, last: closeIndex) + } + index = closeIndex + 1 + } + // Unclosed opener: not frontmatter (Oliver passes it + // through) — fall through to ordinary classification. + } + } + + while index < lines.count { + let line = lines[index] + let trimmed = line.trimmingCharacters(in: .whitespaces) + + // Blank line: block separator, never a block of its own. + if trimmed.isEmpty { + index += 1 + continue + } + + // Indented (tab or ≥4 spaces): code block run. + if Self.isIndented(line) { + var last = index + while last + 1 < lines.count { + let next = lines[last + 1] + let nextTrimmed = next.trimmingCharacters(in: .whitespaces) + if Self.isIndented(next) { + last += 1 + } else if nextTrimmed.isEmpty, last + 2 < lines.count, Self.isIndented(lines[last + 2]) { + // Blank line joins the run only when another + // indented line follows (CommonMark continuation). + last += 1 + } else { + break + } + } + append(.code, first: index, last: last) + index = last + 1 + continue + } + + // Fenced code: ``` or ~~~ run. + if trimmed.hasPrefix("```") || trimmed.hasPrefix("~~~") { + let fenceMarker = String(trimmed.prefix(3)) + var last = index + var closed = false + while last + 1 < lines.count { + last += 1 + if lines[last].trimmingCharacters(in: .whitespaces).hasPrefix(fenceMarker) { + closed = true + break + } + } + _ = closed // unclosed fences still form one block; alignment + // verification catches render-shape drift. + append(.fence, first: index, last: last) + index = last + 1 + continue + } + + // ATX heading: 1–6 `#` + space (or end of line). + if let level = Self.headingLevel(of: trimmed) { + append(.heading(level: level), first: index, last: index) + index += 1 + continue + } + + // Thematic rule: `---`, `***`, `___` (3+ of one char, spaces ok). + if Self.isRule(trimmed) { + append(.rule, first: index, last: index) + index += 1 + continue + } + + // Block quote run: `>` lines. + if trimmed.hasPrefix(">") { + var last = index + while last + 1 < lines.count, lines[last + 1].trimmingCharacters(in: .whitespaces).hasPrefix(">") { + last += 1 + } + append(.quote, first: index, last: last) + index = last + 1 + continue + } + + // List run: `- `/`* `/`+ `/`N. `/`N) ` items (and their + // continuation lines that keep the list shape). + if let firstMarker = Self.listMarker(of: trimmed) { + let ordered = firstMarker.ordered + var last = index + while last + 1 < lines.count { + let next = lines[last + 1] + let nextTrimmed = next.trimmingCharacters(in: .whitespaces) + let marker = Self.listMarker(of: nextTrimmed) + let indentedContinuation = next.hasPrefix(" ") || next.hasPrefix("\t") + if (marker != nil && marker!.ordered == ordered) || indentedContinuation { + last += 1 + } else { + break + } + } + append(.list(ordered: ordered), first: index, last: last) + index = last + 1 + continue + } + + // Setext underline: `===`/`---` alone under a paragraph line — + // the pair is one heading block. + if index > 0, Self.isSetextUnderline(trimmed) { + if let previous = blocks.last, previous.kind == .paragraph, previous.lastLine == index - 1 { + let level = trimmed.hasPrefix("=") ? 1 : 2 + // Replace the paragraph with the heading block. + if !blocks.isEmpty { + blocks.removeLast() + append(.setextHeading(level: level), first: previous.firstLine, last: index) + } + index += 1 + continue + } + } + + // Paragraph run: consecutive plain non-blank lines that are not + // any of the marker shapes above. Soft-break joining happens in + // the renderer; the map keeps the raw run. + var last = index + while last + 1 < lines.count { + let next = lines[last + 1] + let nextTrimmed = next.trimmingCharacters(in: .whitespaces) + if Self.breaksParagraphRun(nextTrimmed, next) { + break + } + last += 1 + } + append(.paragraph, first: index, last: last) + index = last + 1 + } + + return ComposeBlockMap(blocks: blocks, hasFrontmatter: hasFrontmatter) + } + + /// Convenience over `compute(source:frontmatterStripped:)` deriving the + /// strip flag from the render options' frontmatter policy. + static func compute(source: String, options: MarkupRenderOptions) -> ComposeBlockMap { + compute(source: source, frontmatterStripped: options.frontmatter != .none) + } + + // MARK: - Rendered alignment + + /// The rendered-block count the map predicts: every block renders as + /// exactly one block-level element in Oliver's HTML output (paragraphs + /// are soft-break joined, lists are one element, fences one `pre`). + var renderedBlockCount: Int { blocks.count } + + /// Verified alignment: the DOM's block-level child count must equal the + /// predicted count exactly. Any mismatch — misclassification, an + /// extension splitting a paragraph, raw HTML — disables visual editing + /// (the surface shows the plain preview) instead of guessing offsets. + func alignmentMatches(renderedBlockCount: Int) -> Bool { + renderedBlockCount == self.renderedBlockCount + } + + /// The indices (into `blocks`) of blocks that may host a + /// `contenteditable` surface under the given render options. + func editableIndices(options: MarkupRenderOptions) -> Set { + guard Self.textPreserving(options) else { return [] } + return Set(blocks.indices.filter { blocks[$0].isEditableParagraph }) + } + + /// The block containing a rendered-block index, if any. + func block(at renderedIndex: Int) -> Block? { + guard blocks.indices.contains(renderedIndex) else { return nil } + return blocks[renderedIndex] + } + + /// Maps a visual edit event's rendered-text offset to a UTF-16 buffer + /// offset for an editable paragraph block. The block's rendered text is + /// its single source line verbatim, so the translation is the anchor + /// plus the event offset, clamped to the line's length. + func bufferOffset(renderedOffset: Int, in block: Block) -> Int? { + guard block.isEditableParagraph else { return nil } + let lineLength = block.text.utf16.count + let clamped = min(max(renderedOffset, 0), lineLength) + return block.firstLineUTF16 + clamped + } +} + +// MARK: - Line-shape predicates (marker sniffing, no semantics) + +extension ComposeBlockMap { + /// True when a line shape terminates a paragraph run: blank, heading, + /// rule, list marker, quote, fence opener, setext underline, or indent. + static func breaksParagraphRun(_ nextTrimmed: String, _ next: String) -> Bool { + nextTrimmed.isEmpty + || headingLevel(of: nextTrimmed) != nil + || isRule(nextTrimmed) + || isSetextUnderline(nextTrimmed) + || listMarker(of: nextTrimmed) != nil + || nextTrimmed.hasPrefix(">") + || nextTrimmed.hasPrefix("```") + || nextTrimmed.hasPrefix("~~~") + || isIndented(next) + } + + /// Indented line: tab or ≥4 leading spaces (code-block shape). + static func isIndented(_ line: String) -> Bool { + line.hasPrefix("\t") || line.hasPrefix(" ") + } + + /// ATX heading: 1–6 `#` followed by a space or end of line. + static func headingLevel(of trimmed: String) -> Int? { + var poundCount = 0 + for character in trimmed { + if character == "#" { + poundCount += 1 + if poundCount > 6 { return nil } + } else { + break + } + } + guard poundCount >= 1 else { return nil } + let after = trimmed.dropFirst(poundCount) + return after.isEmpty || after.hasPrefix(" ") ? poundCount : nil + } + + /// Thematic rule: 3+ of `-`, `*`, or `_` (one char kind, spaces ok). + static func isRule(_ trimmed: String) -> Bool { + let chars = trimmed.filter { !$0.isWhitespace } + guard chars.count >= 3 else { return false } + let kind = chars.first! + guard kind == "-" || kind == "*" || kind == "_" else { return false } + return chars.allSatisfy { $0 == kind } + } + + /// Setext underline: `=`+ or `-`+ alone. + static func isSetextUnderline(_ trimmed: String) -> Bool { + guard !trimmed.isEmpty else { return false } + let kind = trimmed.first! + guard kind == "=" || kind == "-" else { return false } + return trimmed.allSatisfy { $0 == kind } + } + + /// List marker: `- `, `* `, `+ `, `N. `, `N) `. Returns nil for a + /// non-list line. + static func listMarker(of trimmed: String) -> (ordered: Bool, payload: Void)? { + if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ") { + return (ordered: false, payload: ()) + } + let digits = trimmed.prefix { $0.isNumber } + if !digits.isEmpty { + let after = trimmed.dropFirst(digits.count) + if after.hasPrefix(". ") || after.hasPrefix(") ") { + return (ordered: true, payload: ()) + } + } + return nil + } +} diff --git a/Sources/Compose/ComposeEditorView.swift b/Sources/Compose/ComposeEditorView.swift index fd3f5ae..548b595 100644 --- a/Sources/Compose/ComposeEditorView.swift +++ b/Sources/Compose/ComposeEditorView.swift @@ -47,6 +47,9 @@ struct ComposeEditorView: View { /// #263: toolbar-to-text-view seam for formatting verbs. @State private var formatApplier = ComposeFormatApplier() @State private var showPreviewOptions = false + /// WYSIWYG spike: when on and the language is Markdown, the preview + /// pane becomes the visual editing surface (WYSIWYG-DESIGN.md). + @State private var visualMode = false private var autosaveName: String { "ComposeSplit-\(document.language.rawValue)" @@ -103,15 +106,25 @@ struct ComposeEditorView: View { ) ) if showPreview { - ComposePreviewView( - source: document.text, - language: document.language, - options: previewOptions, - renderService: renderService, - themeCSS: themeCSS, - onDiagnostics: { diagnostics = $0 } - ) - .frame(minWidth: 240, maxWidth: .infinity, maxHeight: .infinity) + if visualMode, document.language == .markdown { + ComposeVisualEditorView( + document: document, + options: previewOptions, + renderService: renderService, + themeCSS: themeCSS + ) + .frame(minWidth: 240, maxWidth: .infinity, maxHeight: .infinity) + } else { + ComposePreviewView( + source: document.text, + language: document.language, + options: previewOptions, + renderService: renderService, + themeCSS: themeCSS, + onDiagnostics: { diagnostics = $0 } + ) + .frame(minWidth: 240, maxWidth: .infinity, maxHeight: .infinity) + } } } if !diagnostics.isEmpty { @@ -214,6 +227,19 @@ struct ComposeEditorView: View { .accessibilityHint("Toggle the Oliver preview pane") .accessibilityAddTraits(showPreview ? .isSelected : []) + // WYSIWYG spike: Markdown buffers only; the visual surface is + // Oliver's own render with editable paragraph blocks. + if document.language == .markdown { + Toggle(isOn: $visualMode) { + Label("Visual", systemImage: "character.cursor.ibeam") + } + .toggleStyle(.button) + .help("Edit the rendered paragraph text in place (spike)") + .accessibilityLabel("Visual") + .accessibilityHint("Toggle visual editing on the preview surface.") + .accessibilityAddTraits(visualMode ? .isSelected : []) + } + Toggle(isOn: frontmatterBinding) { Label("Front Matter", systemImage: "doc.text.magnifyingglass") } @@ -332,52 +358,3 @@ private struct ComposeDiagnosticsPane: View { } } } - -/// #238: "Go to Line" sheet — a compact dialog with a single text field -/// for a 1-based line number. Pre-filled with the cursor's current line; -/// validated and clamped before the jump. -private struct GoToLineSheet: View { - @Binding var isPresented: Bool - let currentLine: Int - let totalLines: Int - var onJump: (Int) -> Void - - @State private var lineNumber = "" - @FocusState private var isFieldFocused: Bool - - var body: some View { - VStack(spacing: 12) { - Text("Go to line (of \(totalLines)):") - .font(.headline) - TextField("Line", text: $lineNumber) - .textFieldStyle(.roundedBorder) - .frame(width: 200) - .onSubmit(go) - .focused($isFieldFocused) - .onAppear { - lineNumber = String(currentLine) - isFieldFocused = true - } - HStack { - Button("Cancel") { isPresented = false } - .keyboardShortcut(.cancelAction) - Button("Go") { go() } - .keyboardShortcut(.defaultAction) - .disabled(Int(lineNumber) == nil) - } - } - .padding() - .frame(width: 280) - .onKeyPress(.escape) { - isPresented = false - return .handled - } - } - - private func go() { - guard let line = Int(lineNumber), line >= 1 else { return } - let clamped = min(line, totalLines) - onJump(clamped) - isPresented = false - } -} diff --git a/Sources/Compose/ComposeMarkupOp.swift b/Sources/Compose/ComposeMarkupOp.swift new file mode 100644 index 0000000..e471996 --- /dev/null +++ b/Sources/Compose/ComposeMarkupOp.swift @@ -0,0 +1,199 @@ +import Foundation + +/// The markup op contract for visual editing (WYSIWYG-DESIGN.md): the +/// closed set of buffer mutations a contenteditable surface may produce. +/// Every op is a pure value; application is a pure function. This is the +/// same posture as the #263 formatting verbs — marker transforms, never a +/// grammar. Boris/Oliver stay the only parser. +enum ComposeMarkupOp: Equatable, Sendable { + /// Insert text at a UTF-16 buffer offset. + case insertText(String, offset: Int) + /// Replace a UTF-16 buffer range with new text (paste, IME commit, + /// selection-typing). + case replaceText(range: NSRange, text: String) + /// Delete a UTF-16 buffer range (backspace, forward-delete, cut). + case deleteText(NSRange) + + /// One visual edit event from the contenteditable surface + /// (`beforeinput`, decoded from the JS bridge). Offsets are UTF-16 + /// within the block's rendered text; for editable blocks that equals + /// the source line verbatim. + struct Event: Equatable, Decodable, Sendable { + let blockIndex: Int + let inputType: String + let start: Int + let end: Int + let data: String? + } + + /// The result of applying an op: the new buffer plus the caret's UTF-16 + /// offset in it (remembered for reconcile-time restoration). + struct Application: Equatable, Sendable { + let text: String + let caret: Int + } + + /// Derives the op for a visual event inside a block map. Returns nil + /// when the event is unmappable — the design rule is *never guess*: + /// the buffer stays untouched and the next reconcile snaps the DOM + /// back to truth. + static func derive(from event: Event, in blockMap: ComposeBlockMap) -> ComposeMarkupOp? { + guard let block = blockMap.block(at: event.blockIndex), block.isEditableParagraph else { + return nil + } + let lineLength = block.text.utf16.count + let start = min(max(event.start, 0), lineLength) + let end = min(max(event.end, start), lineLength) + + switch event.inputType { + case "insertText": + // WebKit only guarantees `data` for plain insertText; a nil here + // means the event carries its payload elsewhere (or none) — + // unmappable, never guess. The next reconcile restores the DOM. + guard let text = event.data else { return nil } + guard let bufferAt = blockMap.bufferOffset(renderedOffset: start, in: block) else { return nil } + if start == end { + return .insertText(text, offset: bufferAt) + } + // bufferAt is already absolute; splice end-relative to it. + return .replaceText(range: NSRange(location: bufferAt, length: end - start), text: text) + case "insertCompositionText", "insertReplacementText", "insertFromPaste", "insertTranspose": + // Out of scope for the spike (WYSIWYG-DESIGN.md "Deliberately + // unmapped"): IME composition commits arrive as a run of these + // mid-composition (splicing half-composed CJK), and WebKit sends + // paste payload on dataTransfer, never `data` (reading the + // clipboard needs an async hop). Nil keeps the buffer untouched; + // the reconcile snaps the DOM back to truth. + return nil + case "deleteContentBackward", "deleteContentForward", "deleteByCut", "deleteByDrag": + return Self.deleteOp(start: start, end: end, block: block, inputType: event.inputType, blockMap: blockMap) + default: + // insertParagraphBreak and everything unrecognized: unmappable + // in the spike (block-level restructure is a follow-up card). + return nil + } + } + + /// Range deletes are direction-agnostic; a collapsed caret deletes one + /// grapheme cluster on the deletion side (surrogate-pair and ZWJ aware). + private static func deleteOp( + start: Int, + end: Int, + block: ComposeBlockMap.Block, + inputType: String, + blockMap: ComposeBlockMap + ) -> ComposeMarkupOp? { + guard let bufferStart = blockMap.bufferOffset(renderedOffset: start, in: block) else { return nil } + let length = end - start + if length > 0 { + return .deleteText(NSRange(location: bufferStart, length: length)) + } + let line = (block.text as NSString) + let direction = inputType == "deleteContentForward" ? +1 : -1 + let range = Self.graphemeExtent(atUTF16: bufferStart - block.firstLineUTF16, direction: direction, in: line) + ?? NSRange(location: 0, length: 0) + guard range.length > 0 else { return nil } + return .deleteText(NSRange(location: block.firstLineUTF16 + range.location, length: range.length)) + } + + /// Applies an operation to a buffer. Pure: returns the new text and caret. + static func apply(_ operation: ComposeMarkupOp, to text: String) -> Application { + let nsText = text as NSString + /// Pure splices via Swift ranges — no NSMutableString casts. + func splice(_ range: NSRange, replacement: String) -> Application { + let safe = clamped(range, length: nsText.length) + let swiftRange = Range(safe, in: text) ?? text.startIndex.. Application? { + derive(from: event, in: blockMap).map { apply($0, to: text) } + } + + // MARK: - Internals + + /// The UTF-16 extent of one grapheme cluster at a caret position, + /// deleted toward `direction`. `atUTF16` is relative to the line. + private static func graphemeExtent(atUTF16 position: Int, direction: Int, in line: NSString) -> NSRange? { + let length = line.length + guard length > 0 else { return nil } + // Caret must sit inside (or at the edge of) the line. + guard position >= 0, position <= length else { return nil } + if direction < 0 { + return backwardClusterExtent(endingAt: position, in: line) + } + guard position < length else { return nil } + var end = position + 1 + if isHighSurrogate(line.character(at: position)), end < length, isLowSurrogate(line.character(at: end)) { + end += 1 + } + while end < length, isContinuationScalar(line.character(at: end)) { + end += 1 + } + return NSRange(location: position, length: end - position) + } + + /// Step back one cluster from a caret: continuation units (ZWJ, bidi + /// marks, combining marks, low surrogates) join leftward, and each low + /// surrogate carries its high partner. A high surrogate joins only + /// when what precedes it continues the cluster (e.g. a ZWJ family: + /// 👨 ZWJ 👩 ZWJ 👧 deletes as one unit). Mirrors the forward path's + /// isContinuationScalar walk. + private static func backwardClusterExtent(endingAt position: Int, in line: NSString) -> NSRange? { + guard position > 0 else { return nil } + var start = position - 1 + while start > 0 { + let unit = line.character(at: start) + if isContinuationScalar(unit) { + start -= 1 + continue + } + if isHighSurrogate(unit), isLowSurrogate(line.character(at: start + 1)), isContinuationScalar(line.character(at: start - 1)) { + start -= 1 + continue + } + break + } + if start > 0, isLowSurrogate(line.character(at: start)), isHighSurrogate(line.character(at: start - 1)) { + start -= 1 + } + return NSRange(location: start, length: position - start) + } + + private static func isHighSurrogate(_ scalar: unichar) -> Bool { + scalar >= 0xD800 && scalar <= 0xDBFF + } + + private static func isLowSurrogate(_ scalar: unichar) -> Bool { + scalar >= 0xDC00 && scalar <= 0xDFFF + } + + /// Continuation UTF-16 units that extend a grapheme cluster: the low + /// half of a surrogate pair, zero-width joiner/bidi marks, and base + /// combining marks (U+0300…U+036F). A following HIGH surrogate is a + /// new character's lead unit, never a continuation. + private static func isContinuationScalar(_ scalar: unichar) -> Bool { + isLowSurrogate(scalar) || scalar == 0x200D || scalar == 0xFEFF || scalar == 0x200E || scalar == 0x200F + || (scalar >= 0x0300 && scalar <= 0x036F) + } + + private static func clamped(_ range: NSRange, length: Int) -> NSRange { + let location = min(max(range.location, 0), length) + let end = min(max(range.location + range.length, location), length) + return NSRange(location: location, length: end - location) + } +} diff --git a/Sources/Compose/ComposeVisualDocument.swift b/Sources/Compose/ComposeVisualDocument.swift new file mode 100644 index 0000000..035d793 --- /dev/null +++ b/Sources/Compose/ComposeVisualDocument.swift @@ -0,0 +1,203 @@ +import Foundation + +/// Assembles the visual-editing document (WYSIWYG-DESIGN.md): the #230 +/// preview document plus the visual bridge script. The script numbers the +/// rendered block-level children (`data-block`), marks the editable index +/// set `contenteditable="plaintext-only"`, forwards `beforeinput` events +/// over the WebKit message handler, intercepts paragraph breaks (spike +/// scope), and exposes caret/scroll restore entry points for reconcile. +/// +/// Pure and unit-testable; the WKWebView host only consumes the result. +enum ComposeVisualDocument { + /// The message-handler name the coordinator registers. + static let messageHandlerName = "composeVisual" + + /// HTML void elements — never carry a close tag, so they open no + /// depth in the block-child counter. + static let voidTags: Set = [ + "area", "base", "br", "col", "embed", "hr", "img", "input", + "link", "meta", "param", "source", "track", "wbr", + ] + + // The visual bridge script (WYSIWYG-DESIGN.md): data-block numbering, + // plaintext-only editable surfaces, beforeinput forwarding, Enter + // interception, and caret/scroll restore. Extracted so `html(_:)` + // stays under the lint body budget. + // swiftlint:disable:next function_body_length + static func bridgeScript(_ editableJSON: String) -> String { + """ + + """ + } + + /// Builds the visual document from an Oliver fragment. + /// - Parameters: + /// - fragment: Oliver's rendered HTML (already the same fragment the + /// preview pane shows). + /// - themeCSS: theme stylesheet; nil → the #230 fallback. + /// - editableBlocks: rendered-block indices that may host a + /// contenteditable surface. + static func html(fragment: String, themeCSS: String?, editableBlocks: Set) -> String { + let css = themeCSS.flatMap { $0.isEmpty ? nil : ComposePreviewDocument.sanitize($0) } + ?? ComposePreviewDocument.fallbackCSS + let editableJSON = Self.jsonInts(editableBlocks) + let script = bridgeScript(editableJSON) + return """ + + + + + + + + + \(fragment) + \(script) + + + """ + } + + /// Stable JSON for the editable index set (sorted, no whitespace). + static func jsonInts(_ indices: Set) -> String { + let sorted = indices.sorted().map(String.init) + return "[\(sorted.joined(separator: ","))]" + } + + /// Counts the top-level block-level children of the HTML fragment's + /// implied body — the number the block map's alignment check compares + /// against. Returns -1 when the fragment cannot be counted (hostile + /// shape → locked). Deliberately not a parse: a depth scan over tags + /// only, ignoring comments/doctype, whitespace-only interstitial text. + static func countBlockLevelChildren(inHTML fragment: String) -> Int { + var depth = 0 + var count = 0 + var scanner = Substring(fragment) + while let open = scanner.firstIndex(of: "<") { + let after = scanner.index(after: open) + guard after < scanner.endIndex else { break } + let character = scanner[after] + if character == "!" || character == "?" { + // Comment / doctype / PI: skip to its close. + let close = scanner[after...].firstIndex(of: ">") ?? scanner.endIndex + scanner = scanner.index(after: close) < scanner.endIndex + ? scanner[scanner.index(after: close)...] + : scanner[scanner.endIndex...] + continue + } + if character == "/" { + depth -= 1 + } else { + if depth == 0 { count += 1 } + // Self-closing and void tags never open a depth. + let close = scanner[after...].firstIndex(of: ">") ?? scanner.endIndex + let tagBody = scanner[after.. scanner.startIndex && scanner[scanner.index(before: close)] == "/" + if isSelfClosing || Self.voidTags.contains(String(name).lowercased()) { + // counts as one block element at depth 0, opens nothing + } else { + depth += 1 + } + } + let next = scanner[after...].firstIndex(of: ">") ?? scanner.endIndex + scanner = next < scanner.endIndex ? scanner[scanner.index(after: next)...] : scanner[scanner.endIndex...] + } + // Unbalanced markup cannot be counted: the caller locks. + return depth == 0 ? count : -1 + } +} diff --git a/Sources/Compose/ComposeVisualEditorView.swift b/Sources/Compose/ComposeVisualEditorView.swift new file mode 100644 index 0000000..b14440f --- /dev/null +++ b/Sources/Compose/ComposeVisualEditorView.swift @@ -0,0 +1,274 @@ +import Foundation +import SwiftUI +import WebKit + +/// The visual-editing pane (WYSIWYG-DESIGN.md): hosts the Oliver-rendered +/// document in a sandboxed WKWebView where the editable paragraph blocks +/// are `contenteditable="plaintext-only"`. DOM edits arrive as +/// `beforeinput` events, are derived into `ComposeMarkupOp`s (pure buffer +/// splices), and applied to `ComposeDocument.text` — the buffer stays the +/// single source of truth; the DOM is ephemeral paint. +/// +/// Reconcile policy: no live reload per keystroke. The pane re-renders +/// through Oliver after edit silence, then restores the caret and scroll. +/// Alignment between the block map and the rendered DOM is verified on +/// every render; a mismatch disables editing (plain preview posture) +/// rather than guessing offsets. +struct ComposeVisualEditorView: View { + @Bindable var document: ComposeDocument + let options: MarkupRenderOptions + let renderService: any MarkupRenderService + var themeCSS: String? + + /// Edit-silence window before a reconcile re-render (ms). + static let reconcileDelay: Duration = .milliseconds(1500) + + @State private var phase: Phase = .rendering(nil) + @State private var reconcileTask: Task? + @State private var lastCaret: Int? + + enum Phase: Equatable { + case rendering(String?) + /// Aligned and editable: the editable index set is live. + case aligned(editableBlocks: Set) + /// Verified misalignment or non-text-preserving options: read-only. + case locked(reason: String) + } + + var body: some View { + Group { + switch phase { + case .rendering(nil): + ProgressView("Rendering…") + .controlSize(.small) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case let .rendering(error?): + ContentUnavailableView { + Label("Render Failed", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + .multilineTextAlignment(.center) + } + case let .aligned(editableBlocks): + visualWebView(editableBlocks: editableBlocks) + case let .locked(reason): + VStack(spacing: 8) { + visualWebView(editableBlocks: []) + Label( + "Visual editing paused: \(reason)", + systemImage: "lock" + ) + .font(.caption) + .foregroundStyle(.secondary) + .padding(8) + } + } + } + .task(id: RenderRequest(language: document.language, options: options)) { + await render() + } + .onDisappear { + reconcileTask?.cancel() + } + } + + private func visualWebView(editableBlocks: Set) -> some View { + ComposeVisualWebView( + html: visualHTML(editableBlocks: editableBlocks), + editableBlocks: editableBlocks, + document: document, + options: options, + blockMap: blockMap, + onVisualEdit: handleVisualEdit + ) + .id(visualDocumentIdentity) + } + + /// The document identity: re-host the web view when the underlying + /// buffer's block structure changes (measured by the block map), so a + /// structural edit gets a fresh DOM instead of a diverging one. + private var visualDocumentIdentity: String { + switch phase { + case let .aligned(blocks): + return "aligned-\(blocks.sorted())" + case .rendering, .locked: + return "static" + } + } + + private var blockMap: ComposeBlockMap { + ComposeBlockMap.compute(source: document.text, options: options) + } + + private func visualHTML(editableBlocks: Set) -> String { + let fragment = renderedFragment ?? "" + return ComposeVisualDocument.html( + fragment: fragment, + themeCSS: themeCSS, + editableBlocks: editableBlocks + ) + } + + /// The last rendered fragment, kept for re-assembly when the editable + /// set changes without a re-render. + @State private var renderedFragment: String? + + private func render() async { + reconcileTask?.cancel() + do { + let rendered = try await renderService.render(document.text, language: document.language, options: options) + guard !Task.isCancelled else { return } + renderedFragment = rendered.html + let map = ComposeBlockMap.compute(source: document.text, options: options) + let domBlocks = Self.countBlockLevelChildren(inHTML: rendered.html) + if map.alignmentMatches(renderedBlockCount: domBlocks) { + let editable = map.editableIndices(options: options) + phase = .aligned(editableBlocks: editable) + } else { + phase = .locked( + reason: domBlocks == -1 + ? "rendered shape could not be counted" + : "source and preview do not line up (\(map.renderedBlockCount) source blocks vs \(domBlocks) rendered)" + ) + } + } catch is CancellationError { + // A newer request superseded this one; keep the last frame. + } catch { + phase = .rendering(String(describing: error)) + } + } + + /// Applies a visual edit: derive the op, splice the buffer, remember + /// the caret, and schedule the reconcile. + private func handleVisualEdit(_ event: ComposeMarkupOp.Event) { + let map = blockMap + guard let application = ComposeMarkupOp.applying(event, to: document.text, in: map) else { + return // Unmappable: never guess. The next reconcile snaps back. + } + document.text = application.text + lastCaret = application.caret + scheduleReconcile() + } + + /// Re-renders after edit silence so the DOM converges with the buffer + /// (Oliver stays the renderer; the visual pane is paint). + private func scheduleReconcile() { + reconcileTask?.cancel() + reconcileTask = Task { + try? await Task.sleep(for: Self.reconcileDelay) + guard !Task.isCancelled else { return } + await render() + } + } + + /// Counts the top-level block-level children of the HTML fragment's + /// implied body — the number the block map's alignment check compares + /// against. Returns -1 when the fragment cannot be counted (hostile + /// shape → locked). Delegates to `ComposeVisualDocument` (pure). + static func countBlockLevelChildren(inHTML fragment: String) -> Int { + ComposeVisualDocument.countBlockLevelChildren(inHTML: fragment) + } +} + +/// The request identity that re-renders on language or option changes. +private struct RenderRequest: Equatable { + let language: ComposeLanguage + let options: MarkupRenderOptions +} + +/// The WKWebView host for the visual pane: registers the message handler, +/// loads the assembled document under the #230 sandbox (non-persistent +/// store, `baseURL: nil`, single allowed main-frame load), and forwards +/// bridge events to the SwiftUI parent. +private struct ComposeVisualWebView: NSViewRepresentable { + let html: String + let editableBlocks: Set + @Bindable var document: ComposeDocument + let options: MarkupRenderOptions + let blockMap: ComposeBlockMap + let onVisualEdit: (ComposeMarkupOp.Event) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onVisualEdit: onVisualEdit) + } + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + configuration.suppressesIncrementalRendering = true + configuration.userContentController.add( + context.coordinator, + name: ComposeVisualDocument.messageHandlerName + ) + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = context.coordinator + context.coordinator.load(html, in: webView) + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + context.coordinator.onVisualEdit = onVisualEdit + context.coordinator.reloadIfChanged(html, in: webView) + } + + @MainActor + final class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { + var onVisualEdit: (ComposeMarkupOp.Event) -> Void + private var lastLoaded: String? + private var initialLoadPending = false + + init(onVisualEdit: @escaping (ComposeMarkupOp.Event) -> Void) { + self.onVisualEdit = onVisualEdit + } + + func load(_ html: String, in webView: WKWebView) { + lastLoaded = html + initialLoadPending = true + webView.loadHTMLString(html, baseURL: nil) + } + + func reloadIfChanged(_ html: String, in webView: WKWebView) { + guard html != lastLoaded else { return } + load(html, in: webView) + } + + // MARK: WKScriptMessageHandler + + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + guard + message.name == ComposeVisualDocument.messageHandlerName, + let body = message.body as? [String: Any], + let data = try? JSONSerialization.data(withJSONObject: body), + let event = try? JSONDecoder().decode(ComposeMarkupOp.Event.self, from: data) + else { return } + onVisualEdit(event) + } + + // MARK: WKNavigationDelegate — the #230 sandbox policy verbatim + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + let isMainFrame = navigationAction.targetFrame?.isMainFrame == true + let allow = ComposePreviewSandbox.allows( + initialLoadPending: initialLoadPending, + isMainFrame: isMainFrame + ) + if isMainFrame { initialLoadPending = false } + decisionHandler(allow ? .allow : .cancel) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + initialLoadPending = false + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + initialLoadPending = false + } + } +} diff --git a/Sources/Compose/GoToLineSheet.swift b/Sources/Compose/GoToLineSheet.swift new file mode 100644 index 0000000..a2c8230 --- /dev/null +++ b/Sources/Compose/GoToLineSheet.swift @@ -0,0 +1,50 @@ +import SwiftUI + +/// #238: "Go to Line" sheet — a compact dialog with a single text field +/// for a 1-based line number. Pre-filled with the cursor's current line; +/// validated and clamped before the jump. +struct GoToLineSheet: View { + @Binding var isPresented: Bool + let currentLine: Int + let totalLines: Int + var onJump: (Int) -> Void + + @State private var lineNumber = "" + @FocusState private var isFieldFocused: Bool + + var body: some View { + VStack(spacing: 12) { + Text("Go to line (of \(totalLines)):") + .font(.headline) + TextField("Line", text: $lineNumber) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + .onSubmit(go) + .focused($isFieldFocused) + .onAppear { + lineNumber = String(currentLine) + isFieldFocused = true + } + HStack { + Button("Cancel") { isPresented = false } + .keyboardShortcut(.cancelAction) + Button("Go") { go() } + .keyboardShortcut(.defaultAction) + .disabled(Int(lineNumber) == nil) + } + } + .padding() + .frame(width: 280) + .onKeyPress(.escape) { + isPresented = false + return .handled + } + } + + private func go() { + guard let line = Int(lineNumber), line >= 1 else { return } + let clamped = min(line, totalLines) + onJump(clamped) + isPresented = false + } +} diff --git a/Tests/ContractTests/ComposeBlockMapTests.swift b/Tests/ContractTests/ComposeBlockMapTests.swift new file mode 100644 index 0000000..aeba198 --- /dev/null +++ b/Tests/ContractTests/ComposeBlockMapTests.swift @@ -0,0 +1,196 @@ +import XCTest + +/// WYSIWYG spike — the block map contract (WYSIWYG-DESIGN.md): line-shape +/// classification, the editable-paragraph predicate, frontmatter policy +/// alignment, and verified (never assumed) source/rendered alignment. +final class ComposeBlockMapTests: XCTestCase { + // MARK: - Classification + + func testPlainParagraphClassification() { + let map = ComposeBlockMap.compute(source: "Hello world\n\nSecond paragraph here.\n", frontmatterStripped: false) + XCTAssertEqual(map.blocks.count, 2) + XCTAssertEqual(map.blocks[0].kind, .paragraph) + XCTAssertEqual(map.blocks[0].firstLine, 0) + XCTAssertEqual(map.blocks[0].lastLine, 0) + XCTAssertEqual(map.blocks[1].kind, .paragraph) + XCTAssertEqual(map.blocks[1].text, "Second paragraph here.") + } + + func testSoftBreakParagraphRun() { + let map = ComposeBlockMap.compute(source: "line one\nline two\nline three\n\nnext", frontmatterStripped: false) + XCTAssertEqual(map.blocks.count, 2) + XCTAssertEqual(map.blocks[0].kind, .paragraph) + XCTAssertEqual(map.blocks[0].firstLine, 0) + XCTAssertEqual(map.blocks[0].lastLine, 2) + XCTAssertEqual(map.blocks[0].text, "line one\nline two\nline three") + } + + func testHeadingClassification() { + let map = ComposeBlockMap.compute(source: "# Title\n## Sub\n### Deep\n", frontmatterStripped: false) + XCTAssertEqual(map.blocks.map(\.kind), [.heading(level: 1), .heading(level: 2), .heading(level: 3)]) + } + + func testSetextHeadingFusesWithParagraph() { + let map = ComposeBlockMap.compute(source: "Title text\n=========\n\nbody", frontmatterStripped: false) + XCTAssertEqual(map.blocks.count, 2) + XCTAssertEqual(map.blocks[0].kind, .setextHeading(level: 1)) + XCTAssertEqual(map.blocks[0].firstLine, 0) + XCTAssertEqual(map.blocks[0].lastLine, 1) + } + + func testListRuns() { + let map = ComposeBlockMap.compute(source: "- one\n- two\n continuation\n\n1. first\n2. second\n", frontmatterStripped: false) + XCTAssertEqual(map.blocks.count, 2) + XCTAssertEqual(map.blocks[0].kind, .list(ordered: false)) + XCTAssertEqual(map.blocks[0].lastLine, 2) // continuation joined + XCTAssertEqual(map.blocks[1].kind, .list(ordered: true)) + } + + func testQuoteRunAndFence() { + let source = """ + > quoted line + > more quote + + ``` + fenced + content + ``` + """ + let map = ComposeBlockMap.compute(source: source, frontmatterStripped: false) + XCTAssertEqual(map.blocks.map(\.kind), [.quote, .fence]) + } + + func testIndentedCodeBlock() { + // A blank line between code and paragraph does NOT join the run + // (only blank lines followed by more indented text continue it). + let map = ComposeBlockMap.compute(source: " indented code\n second line\n\npara\n", frontmatterStripped: false) + XCTAssertEqual(map.blocks[0].kind, .code) + XCTAssertEqual(map.blocks[0].lastLine, 1) + XCTAssertEqual(map.blocks.count, 2) + XCTAssertEqual(map.blocks[1].kind, .paragraph) + // Blank line sandwiched between indented lines continues the run. + let joined = ComposeBlockMap.compute(source: " a\n\n b\n", frontmatterStripped: false) + XCTAssertEqual(joined.blocks.count, 1) + XCTAssertEqual(joined.blocks[0].kind, .code) + XCTAssertEqual(joined.blocks[0].lastLine, 2) + } + + func testRule() { + let map = ComposeBlockMap.compute(source: "above\n\n---\n\nbelow\n", frontmatterStripped: false) + XCTAssertEqual(map.blocks.map(\.kind), [.paragraph, .rule, .paragraph]) + } + + // MARK: - Frontmatter + + func testFrontmatterStrippedVsPassthrough() { + let source = "---\ntitle: x\n---\n\nBody paragraph.\n" + let stripped = ComposeBlockMap.compute(source: source, frontmatterStripped: true) + let passthrough = ComposeBlockMap.compute(source: source, frontmatterStripped: false) + XCTAssertTrue(stripped.hasFrontmatter) + XCTAssertTrue(passthrough.hasFrontmatter) + // Stripped: the map skips it (Oliver removes it from the render). + XCTAssertEqual(stripped.blocks.count, 1) + XCTAssertEqual(stripped.blocks[0].kind, .paragraph) + // Passthrough: it is a block (rendered as some element). + XCTAssertEqual(passthrough.blocks.count, 2) + XCTAssertEqual(passthrough.blocks[0].kind, .frontmatter) + } + + func testUnclosedFrontmatterIsNotFrontmatter() { + // Oliver passes an unclosed opener through with a diagnostic; the + // map must not treat it as frontmatter (it renders as content). + let map = ComposeBlockMap.compute(source: "---\ntitle: x\n\nbody\n", frontmatterStripped: true) + XCTAssertFalse(map.hasFrontmatter) + XCTAssertFalse(map.blocks.contains { $0.kind == .frontmatter }) + } + + // MARK: - Editable predicate + + func testEditableParagraphPredicate() { + XCTAssertTrue(ComposeBlockMap.isEditableText("Hello plain world")) + XCTAssertTrue(ComposeBlockMap.isEditableText("Ünïcode with — dashes and quotes")) + XCTAssertFalse(ComposeBlockMap.isEditableText("")) // empty + XCTAssertFalse(ComposeBlockMap.isEditableText("two\nlines")) + // Every marker character disqualifies: + for marker in ["*bold*", "_em_", "`code`", "~~strike~~", "[link](x)", "", "&", "a\\b", "{brace}"] { + XCTAssertFalse(ComposeBlockMap.isEditableText(marker), "expected non-editable: \(marker)") + } + } + + func testEditableIndicesTextPreservingOnly() { + let source = "plain para\n\n*marked* para\n\n- list item\n" + let map = ComposeBlockMap.compute(source: source, frontmatterStripped: false) + let defaults = MarkupRenderOptions() + XCTAssertEqual(map.editableIndices(options: defaults), [0]) // only the plain one + var transforming = defaults + transforming.smartypants = true + XCTAssertEqual(map.editableIndices(options: transforming), []) // text-transforming: none + transforming = defaults + transforming.wikilinks = true + XCTAssertEqual(map.editableIndices(options: transforming), []) + } + + // MARK: - Alignment + + func testAlignmentMatchAndMismatch() { + let map = ComposeBlockMap.compute(source: "one\n\ntwo\n\nthree\n", frontmatterStripped: false) + XCTAssertTrue(map.alignmentMatches(renderedBlockCount: 3)) + XCTAssertFalse(map.alignmentMatches(renderedBlockCount: 4)) + XCTAssertFalse(map.alignmentMatches(renderedBlockCount: 2)) + } + + // MARK: - Buffer offset translation + + func testBufferOffsetTranslation() { + let source = "---\ntitle: t\n---\n\nFirst paragraph.\n\nSecond editable paragraph.\n" + let map = ComposeBlockMap.compute(source: source, frontmatterStripped: true) + let block = map.blocks[0] // the first paragraph (frontmatter skipped) + XCTAssertEqual(block.text, "First paragraph.") + // Rendered offset 5 within the block → buffer anchor + 5. + XCTAssertEqual(map.bufferOffset(renderedOffset: 5, in: block), block.firstLineUTF16 + 5) + XCTAssertEqual(block.firstLineUTF16, 18) // after "---\ntitle: t\n---\n\n" + // Clamp beyond the line length. + XCTAssertEqual(map.bufferOffset(renderedOffset: 999, in: block), block.firstLineUTF16 + "First paragraph.".utf16.count) + } + + func testBufferOffsetRejectsNonEditable() { + let map = ComposeBlockMap.compute(source: "*marked*\n\nplain\n", frontmatterStripped: false) + let marked = map.blocks[0] + XCTAssertEqual(marked.kind, .paragraph) + XCTAssertFalse(marked.isEditableParagraph) + XCTAssertNil(map.bufferOffset(renderedOffset: 0, in: marked)) + } + + // MARK: - Line-shape predicates + + func testHeadingLevelPredicate() { + XCTAssertEqual(ComposeBlockMap.headingLevel(of: "# x"), 1) + XCTAssertEqual(ComposeBlockMap.headingLevel(of: "###### x"), 6) + XCTAssertEqual(ComposeBlockMap.headingLevel(of: "####### x"), nil) // 7 + XCTAssertEqual(ComposeBlockMap.headingLevel(of: "#nospace"), nil) + XCTAssertEqual(ComposeBlockMap.headingLevel(of: "#"), 1) // bare + XCTAssertNil(ComposeBlockMap.headingLevel(of: "plain")) + } + + func testListMarkerPredicate() { + XCTAssertNotNil(ComposeBlockMap.listMarker(of: "- item")) + XCTAssertNotNil(ComposeBlockMap.listMarker(of: "* item")) + XCTAssertNotNil(ComposeBlockMap.listMarker(of: "+ item")) + XCTAssertNotNil(ComposeBlockMap.listMarker(of: "12. item")) + XCTAssertNotNil(ComposeBlockMap.listMarker(of: "3) item")) + XCTAssertNil(ComposeBlockMap.listMarker(of: "plain text")) + XCTAssertNil(ComposeBlockMap.listMarker(of: "-no space")) + } + + func testRuleAndSetextPredicates() { + XCTAssertTrue(ComposeBlockMap.isRule("---")) + XCTAssertTrue(ComposeBlockMap.isRule("***")) + XCTAssertTrue(ComposeBlockMap.isRule("___")) + XCTAssertTrue(ComposeBlockMap.isRule("- - -")) + XCTAssertFalse(ComposeBlockMap.isRule("--")) + XCTAssertFalse(ComposeBlockMap.isRule("-a-")) + XCTAssertTrue(ComposeBlockMap.isSetextUnderline("===")) + XCTAssertTrue(ComposeBlockMap.isSetextUnderline("---")) + XCTAssertFalse(ComposeBlockMap.isSetextUnderline("==x")) + } +} diff --git a/Tests/ContractTests/ComposeMarkupOpTests.swift b/Tests/ContractTests/ComposeMarkupOpTests.swift new file mode 100644 index 0000000..e1f21f4 --- /dev/null +++ b/Tests/ContractTests/ComposeMarkupOpTests.swift @@ -0,0 +1,263 @@ +import XCTest + +/// WYSIWYG spike — the markup op contract (WYSIWYG-DESIGN.md): derivation +/// from visual events, pure application, surrogate-pair-aware collapsed +/// deletes, and the never-guess nil path for unmappable events. +/// Mirrors ComposeBlockMapTests' style: small focused cases, plain fixtures. +final class ComposeMarkupOpTests: XCTestCase { + // MARK: - Fixtures + + private func map(_ source: String) -> ComposeBlockMap { + ComposeBlockMap.compute(source: source, frontmatterStripped: false) + } + + /// Two paragraph blocks; block 1 is the edit target. + private var twoParagraphs: ComposeBlockMap { + map("First paragraph.\n\nSecond paragraph, the target.\n") + } + + private func event( + _ inputType: String, + block: Int, + start: Int, + end: Int? = nil, + data: String? = nil + ) -> ComposeMarkupOp.Event { + ComposeMarkupOp.Event(blockIndex: block, inputType: inputType, start: start, end: end ?? start, data: data) + } + + // MARK: - Derivation: insert + + func testInsertTextCollapsedCaretDerivesInsert() { + let derived = ComposeMarkupOp.derive( + from: event("insertText", block: 1, start: 6, data: "!"), + in: twoParagraphs + ) + XCTAssertEqual(derived, .insertText("!", offset: "First paragraph.\n\nSecond".utf16.count)) + } + + func testInsertTextClampsRenderedOffsetsToLine() { + // start/end far past the line end must clamp, not crash or guess. + let derived = ComposeMarkupOp.derive( + from: event("insertText", block: 1, start: 999, end: 999, data: "x"), + in: twoParagraphs + ) + XCTAssertEqual(derived, .insertText("x", offset: "First paragraph.\n\nSecond paragraph, the target.".utf16.count)) + } + + func testInsertTextWithSelectionDerivesReplace() { + let derived = ComposeMarkupOp.derive( + from: event("insertText", block: 1, start: 8, end: 14, data: "EDITED"), + in: twoParagraphs + ) + let base = "First paragraph.".utf16.count + 2 // 18: block 1 starts after the blank line + XCTAssertEqual(derived, .replaceText(range: NSRange(location: base + 8, length: 14 - 8), text: "EDITED")) + } + + func testInsertTextWithNilDataReturnsNil() { + // WebKit only guarantees `data` for plain insertText; nil is + // unmappable — never guess an empty insert over a selection. + XCTAssertNil(ComposeMarkupOp.derive( + from: event("insertText", block: 1, start: 8, end: 14), + in: twoParagraphs + )) + } + + // MARK: - Derivation: deliberately unmapped (WYSIWYG-DESIGN.md) + + func testInsertCompositionTextReturnsNil() { + // Mid-IME half-composed text must never splice; reconcile restores. + XCTAssertNil(ComposeMarkupOp.derive( + from: event("insertCompositionText", block: 1, start: 0, data: "初"), + in: twoParagraphs + )) + } + + func testInsertFromPasteReturnsNil() { + // WebKit sends paste payload on dataTransfer, never `data`; the + // spike does not read the clipboard — buffer stays untouched. + XCTAssertNil(ComposeMarkupOp.derive( + from: event("insertFromPaste", block: 1, start: 8, end: 14, data: "pasted"), + in: twoParagraphs + )) + } + + func testInsertReplacementTextReturnsNil() { + XCTAssertNil(ComposeMarkupOp.derive( + from: event("insertReplacementText", block: 1, start: 0, end: 6, data: "spellcorrected"), + in: twoParagraphs + )) + } + + func testInsertTransposeReturnsNil() { + XCTAssertNil(ComposeMarkupOp.derive( + from: event("insertTranspose", block: 1, start: 0, end: 2), + in: twoParagraphs + )) + } + + // MARK: - Derivation: delete family + + func testDeleteRangeBackward() { + let derived = ComposeMarkupOp.derive( + from: event("deleteContentBackward", block: 1, start: 8, end: 14), + in: twoParagraphs + ) + XCTAssertEqual(derived, .deleteText(NSRange(location: "First paragraph.\n\n".utf16.count + 8, length: 6))) + } + + func testDeleteRangeForwardMatchesBackward() { + // Range deletes are direction-agnostic. + let backward = ComposeMarkupOp.derive( + from: event("deleteContentBackward", block: 1, start: 8, end: 14), + in: twoParagraphs + ) + let forward = ComposeMarkupOp.derive( + from: event("deleteContentForward", block: 1, start: 8, end: 14), + in: twoParagraphs + ) + XCTAssertEqual(backward, forward) + } + + func testDeleteByCutAndDragMapLikeBackward() { + let cut = ComposeMarkupOp.derive( + from: event("deleteByCut", block: 1, start: 8, end: 14), + in: twoParagraphs + ) + XCTAssertEqual(cut, .deleteText(NSRange(location: "First paragraph.\n\n".utf16.count + 8, length: 6))) + } + + func testCollapsedBackspaceDeletesOneCharacter() { + let derived = ComposeMarkupOp.derive( + from: event("deleteContentBackward", block: 1, start: 6, end: 6), + in: twoParagraphs + ) + XCTAssertEqual(derived, .deleteText(NSRange(location: "First paragraph.\n\nSecon".utf16.count, length: 1))) + } + + func testCollapsedBackspaceKeepsSurrogatePairWhole() { + let emojiMap = map("emoji 🎉 here\n\nsecond\n") + // Caret just after 🎉 (U+1F389): rendered offset after the pair. + let after = "emoji ".utf16.count + 2 + let derived = ComposeMarkupOp.derive( + from: event("deleteContentBackward", block: 0, start: after, end: after), + in: emojiMap + ) + XCTAssertEqual(derived, .deleteText(NSRange(location: "emoji ".utf16.count, length: 2))) + } + + func testCollapsedForwardDeleteKeepsSurrogatePairWhole() { + let emojiMap = map("emoji 🎉 here\n\nsecond\n") + let before = "emoji ".utf16.count + let derived = ComposeMarkupOp.derive( + from: event("deleteContentForward", block: 0, start: before, end: before), + in: emojiMap + ) + XCTAssertEqual(derived, .deleteText(NSRange(location: before, length: 2))) + } + + func testCollapsedBackspaceZWJFamilyNotSplit() { + // 👨‍👩‍👧 family = 3 scalars + 2 ZWJ = 8 UTF-16 units. + let familyMap = map("family 👨‍👩‍👧 end\n\nsecond\n") + let after = "family ".utf16.count + 8 + let derived = ComposeMarkupOp.derive( + from: event("deleteContentBackward", block: 0, start: after, end: after), + in: familyMap + ) + XCTAssertEqual(derived, .deleteText(NSRange(location: "family ".utf16.count, length: 8))) + } + + func testCollapsedDeleteAtLineStartReturnsNil() { + // Caret at 0 with backward direction: nothing to delete — nil, not a guess. + XCTAssertNil(ComposeMarkupOp.derive( + from: event("deleteContentBackward", block: 1, start: 0, end: 0), + in: twoParagraphs + )) + } + + // MARK: - Derivation: never guess + + func testInsertParagraphBreakReturnsNil() { + XCTAssertNil(ComposeMarkupOp.derive( + from: event("insertParagraphBreak", block: 1, start: 5), + in: twoParagraphs + )) + } + + func testUnrecognizedInputTypeReturnsNil() { + XCTAssertNil(ComposeMarkupOp.derive( + from: event("formatBold", block: 1, start: 0, end: 4), + in: twoParagraphs + )) + } + + func testEventOnNonEditableBlockReturnsNil() { + // Block 0 is a heading — not an editable paragraph. + let headingMap = map("# Heading\n\nbody text\n") + XCTAssertNil(ComposeMarkupOp.derive(from: event("insertText", block: 0, start: 0, data: "x"), in: headingMap)) + } + + func testEventOnMultiLineParagraphRunReturnsNil() { + // Soft-break runs are paragraphs but multi-line → not editable. + let runMap = map("line one\nline two\n") + XCTAssertNil(ComposeMarkupOp.derive(from: event("insertText", block: 0, start: 2, data: "x"), in: runMap)) + } + + func testEventPastBlockCountReturnsNil() { + XCTAssertNil(ComposeMarkupOp.derive(from: event("insertText", block: 9, start: 0, data: "x"), in: twoParagraphs)) + } + + // MARK: - Application + + func testApplyInsert() { + let app = ComposeMarkupOp.apply(.insertText("!", offset: 6), to: "Hello world") + XCTAssertEqual(app.text, "Hello !world") + XCTAssertEqual(app.caret, 7) + } + + func testApplyReplace() { + let app = ComposeMarkupOp.apply(.replaceText(range: NSRange(location: 6, length: 5), text: "there"), to: "Hello world") + XCTAssertEqual(app.text, "Hello there") + XCTAssertEqual(app.caret, 11) + } + + func testApplyDelete() { + let app = ComposeMarkupOp.apply(.deleteText(NSRange(location: 5, length: 6)), to: "Hello world") + XCTAssertEqual(app.text, "Hello") + XCTAssertEqual(app.caret, 5) + } + + func testApplyInsertAfterSurrogatePairDoesNotCorruptIt() { + let app = ComposeMarkupOp.apply(.insertText("x", offset: 3), to: "a🎉b") // after the pair + XCTAssertEqual(app.text, "a🎉xb") + } + + func testApplyClampsOutOfRangeRanges() { + let app = ComposeMarkupOp.apply(.deleteText(NSRange(location: 8, length: 100)), to: "short") + XCTAssertEqual(app.text, "short") + XCTAssertEqual(app.caret, 5) + } + + func testApplyCaretCountsUTF16Units() { + // CJK inserted text — caret counts UTF-16 units, not scalars. + let app = ComposeMarkupOp.apply(.insertText("初音", offset: 0), to: "") + XCTAssertEqual(app.text, "初音") + XCTAssertEqual(app.caret, 2) + } + + // MARK: - Convenience + + func testApplyingMatchesDeriveThenApply() { + let insertEvent = event("insertText", block: 1, start: 6, data: "!") + let source = "First paragraph.\n\nSecond paragraph, the target.\n" + let applied = ComposeMarkupOp.applying(insertEvent, to: source, in: twoParagraphs) + let derived = ComposeMarkupOp.derive(from: insertEvent, in: twoParagraphs).map { ComposeMarkupOp.apply($0, to: source) } + XCTAssertEqual(applied?.text, derived?.text) + XCTAssertEqual(applied?.caret, derived?.caret) + } + + func testApplyingUnmappableLeavesBufferUntouched() { + let breakEvent = event("insertParagraphBreak", block: 1, start: 5) + XCTAssertNil(ComposeMarkupOp.applying(breakEvent, to: "First paragraph.\n\nSecond paragraph, the target.\n", in: twoParagraphs)) + } +} diff --git a/Tests/ContractTests/ComposeVisualDocumentTests.swift b/Tests/ContractTests/ComposeVisualDocumentTests.swift new file mode 100644 index 0000000..3165542 --- /dev/null +++ b/Tests/ContractTests/ComposeVisualDocumentTests.swift @@ -0,0 +1,92 @@ +import XCTest + +/// WYSIWYG spike — the visual document assembly (WYSIWYG-DESIGN.md): script +/// presence, editable gating by index set, the #230 style-close guard +/// inheritance, and the fragment block-child counter used for alignment. +final class ComposeVisualDocumentTests: XCTestCase { + func testAssemblyCarriesBridgeAndEditableSet() { + let html = ComposeVisualDocument.html( + fragment: "

plain

\n

*marked*

", + themeCSS: nil, + editableBlocks: [0] + ) + XCTAssertTrue(html.hasPrefix("")) + // The message-handler bridge is present under its pinned name. + XCTAssertTrue(html.contains("window.webkit.messageHandlers.composeVisual")) + // The editable set serializes into the script. + XCTAssertTrue(html.contains("new Set([0])")) + // Both blocks get the script's data-block numbering logic (the + // attribute assignment lives in the script, not the fragment). + XCTAssertTrue(html.contains("setAttribute('data-block'")) + XCTAssertTrue(html.contains("contenteditable")) + } + + func testEditableSetSortedJSON() { + XCTAssertEqual(ComposeVisualDocument.jsonInts([3, 1, 2]), "[1,2,3]") + XCTAssertEqual(ComposeVisualDocument.jsonInts([]), "[]") + } + + func testEnterInterceptionPresent() { + let html = ComposeVisualDocument.html(fragment: "

x

", themeCSS: nil, editableBlocks: [0]) + XCTAssertTrue(html.contains("insertParagraphBreak")) + XCTAssertTrue(html.contains("preventDefault")) + } + + func testUnmappedInputTypesPreventDefaulted() { + // The DOM side must hold for the op-set gap (paste/composition/etc.): + // paint never desyncs from the buffer awaiting the reconcile. + let html = ComposeVisualDocument.html(fragment: "

x

", themeCSS: nil, editableBlocks: [0]) + XCTAssertTrue(html.contains("insertFromPaste") == false) // not special-cased by name… + XCTAssertTrue(html.contains("event.inputType !== 'insertText'")) // …but excluded by the op set + XCTAssertTrue(html.contains("indexOf('deleteContent')")) + } + + func testStyleCloseGuardInherited() { + let hostile = "a::after { content: \"\"; }" + let html = ComposeVisualDocument.html(fragment: "

x

", themeCSS: hostile, editableBlocks: []) + XCTAssertFalse(html.contains("")) + } + + func testFallbackCSSWhenNoTheme() { + let html = ComposeVisualDocument.html(fragment: "

x

", themeCSS: nil, editableBlocks: []) + XCTAssertTrue(html.contains(ComposePreviewDocument.fallbackCSS)) + } + + // MARK: - Block-child counter (alignment input) + + func testCountBlockLevelChildrenSimple() { + XCTAssertEqual(ComposeVisualDocument.countBlockLevelChildren(inHTML: "

a

\n

b

"), 2) + XCTAssertEqual(ComposeVisualDocument.countBlockLevelChildren(inHTML: "

a

"), 1) + XCTAssertEqual(ComposeVisualDocument.countBlockLevelChildren(inHTML: ""), 0) + } + + func testCountBlockLevelChildrenNested() { + // A list is ONE top-level element containing nested ones. + XCTAssertEqual( + ComposeVisualDocument.countBlockLevelChildren(inHTML: "
  • a
  • b
"), + 1 + ) + XCTAssertEqual( + ComposeVisualDocument.countBlockLevelChildren( + inHTML: "

t

\n
  • a
    • n
\n

x

" + ), + 3 + ) + } + + func testCountBlockLevelChildrenCommentsIgnored() { + XCTAssertEqual( + ComposeVisualDocument.countBlockLevelChildren(inHTML: "

a

"), + 1 + ) + } + + func testCountBlockLevelChildrenSelfClosing() { + XCTAssertEqual(ComposeVisualDocument.countBlockLevelChildren(inHTML: "

a

"), 2) + XCTAssertEqual(ComposeVisualDocument.countBlockLevelChildren(inHTML: "
"), 1) + } + + func testCountBlockLevelChildrenUnbalancedIsMinusOne() { + XCTAssertEqual(ComposeVisualDocument.countBlockLevelChildren(inHTML: "

unclosed"), -1) + } +} diff --git a/Tests/ContractTests/ComposeVisualEditorE2ETests.swift b/Tests/ContractTests/ComposeVisualEditorE2ETests.swift new file mode 100644 index 0000000..c2792d2 --- /dev/null +++ b/Tests/ContractTests/ComposeVisualEditorE2ETests.swift @@ -0,0 +1,167 @@ +import WebKit +import XCTest + +/// WYSIWYG spike — one real-WKWebView round trip (the #230 integration +/// pattern): the assembled visual document loads, the script numbers the +/// blocks and marks the editable set, a synthetic `beforeinput` on an +/// editable block produces a bridge message that derives an op and splices +/// the buffer, and Enter is intercepted. +@MainActor +final class ComposeVisualEditorE2ETests: XCTestCase { + // swiftlint:disable:next function_body_length + func testVisualRoundTripInsertsIntoBuffer() async throws { + let source = "Hello plain world\n\nSecond plain line\n" + let blockMap = ComposeBlockMap.compute(source: source, frontmatterStripped: false) + let editable = blockMap.editableIndices(options: MarkupRenderOptions()) + XCTAssertEqual(editable, [0, 1]) + + let document = ComposeDocument() + document.text = source + document.language = .markdown + + let html = ComposeVisualDocument.html( + fragment: "

Hello plain world

\n

Second plain line

", + themeCSS: nil, + editableBlocks: editable + ) + + // Host the document with a message-capturing coordinator (same + // sandbox posture as the production view). + let bridge = VisualBridgeCollector() + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + configuration.userContentController.add( + bridge, + name: ComposeVisualDocument.messageHandlerName + ) + let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 500, height: 400), configuration: configuration) + let policy = ComposePreviewCoordinator() + webView.navigationDelegate = policy + policy.load(html, in: webView) + + // Wait for the script to number the blocks. + try await waitUntil("blocks numbered") { + let count = try await webView.evaluateJavaScript("window.__composeVisualBlocks") as? Int + return count == 2 + } + + // The editable block carries the contenteditable surface. + let editable0 = try await webView.evaluateJavaScript( + "document.querySelector('[data-block=\"0\"]').getAttribute('contenteditable')" + ) as? String + XCTAssertEqual(editable0, "plaintext-only") + + // Synthetic beforeinput at caret 5 on block 0, typing "!". + let messageJSON = try await webView.evaluateJavaScript(#""" + (function () { + var el = document.querySelector('[data-block="0"]'); + var sel = window.getSelection(); + var range = document.createRange(); + range.setStart(el.firstChild, 5); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + var ev = new InputEvent('beforeinput', { + inputType: 'insertText', data: '!', bubbles: true, cancelable: true + }); + el.dispatchEvent(ev); + return 'dispatched'; + })(); + """#) as? String + XCTAssertEqual(messageJSON, "dispatched") + + // The bridge message arrived… + let event = try await bridge.nextMessage() + XCTAssertEqual(event.blockIndex, 0) + XCTAssertEqual(event.inputType, "insertText") + XCTAssertEqual(event.start, 5) + XCTAssertEqual(event.data, "!") + + // …and deriving + applying the op edits the buffer. + let applied = try XCTUnwrap(ComposeMarkupOp.applying(event, to: document.text, in: blockMap)) + document.text = applied.text + XCTAssertEqual(document.text.hasPrefix("Hello! plain world"), true) + XCTAssertTrue(document.isDirty, "visual edits mark the buffer dirty like typed edits") + } + + func testEnterIsIntercepted() async throws { + let html = ComposeVisualDocument.html( + fragment: "

plain

", + themeCSS: nil, + editableBlocks: [0] + ) + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 400, height: 300), configuration: configuration) + let policy = ComposePreviewCoordinator() + webView.navigationDelegate = policy + policy.load(html, in: webView) + + try await waitUntil("blocks numbered") { + let count = try await webView.evaluateJavaScript("window.__composeVisualBlocks") as? Int + return count == 1 + } + + // Dispatching insertParagraphBreak reports the event as cancelled + // (preventDefault ran): no bridge message, no DOM break. + let cancelled = try await webView.evaluateJavaScript(#""" + (function () { + var el = document.querySelector('[data-block="0"]'); + var ev = new InputEvent('beforeinput', { + inputType: 'insertParagraphBreak', bubbles: true, cancelable: true + }); + return el.dispatchEvent(ev) ? 'not-cancelled' : 'cancelled'; + })(); + """#) as? String + XCTAssertEqual(cancelled, "cancelled") + } + + // MARK: - Helpers + + @MainActor + private final class VisualBridgeCollector: NSObject, WKScriptMessageHandler { + private var messages: [ComposeMarkupOp.Event] = [] + private var continuation: CheckedContinuation? + + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + guard + message.name == ComposeVisualDocument.messageHandlerName, + let body = message.body as? [String: Any], + let data = try? JSONSerialization.data(withJSONObject: body), + let event = try? JSONDecoder().decode(ComposeMarkupOp.Event.self, from: data) + else { return } + if let continuation { + self.continuation = nil + continuation.resume(returning: event) + } else { + messages.append(event) + } + } + + func nextMessage() async throws -> ComposeMarkupOp.Event { + if let first = messages.first { + messages.removeFirst() + return first + } + return await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + } + + private func waitUntil( + _ description: String, + timeout seconds: TimeInterval = 5, + _ condition: () async throws -> Bool + ) async throws { + let deadline = Date().addingTimeInterval(seconds) + while Date() < deadline { + if try await condition() { return } + try await Task.sleep(for: .milliseconds(50)) + } + XCTFail("Timed out waiting for \(description)") + } +} diff --git a/docs/WYSIWYG-DESIGN.md b/docs/WYSIWYG-DESIGN.md new file mode 100644 index 0000000..c5c64f7 --- /dev/null +++ b/docs/WYSIWYG-DESIGN.md @@ -0,0 +1,208 @@ +# WYSIWYG Compose — Visual Editing Spike & Op Contract + +| Field | Value | +|-------|--------| +| **Title** | WYSIWYG Compose — Visual Editing Spike & Op Contract | +| **Author** | Solipsist design lane (dme) | +| **Date** | 2026-09-06 | +| **Status** | Spike (proposed) | +| **Repo** | [drawmeanelephant/solipsist](https://github.com/drawmeanelephant/solipsist) | +| **Parents** | [`ROADMAP.md`](ROADMAP.md) §3 v1-must-not (no homegrown renderer) · [`AGENTS.md`](../AGENTS.md) boundaries | + +## What this is + +The compose window today is a textarea + Oliver preview: the author edits +markup and *reads* rendered output. This spike adds visual editing — typing +into the rendered surface — for **one node type** (the plain single-line +paragraph) and pins the **markup op contract** every further node type +reuses. It is a spike, not the finished editor: the contract and the +boundaries are the deliverable, the paragraph proves them end to end. + +## Locked rules (do not reopen) + +1. **No parallel document model.** The DOM inside the visual surface is + *ephemeral paint*, exactly like the highlighter's colored attributes. + `ComposeDocument.text` stays the single source of truth. +2. **Oliver stays the only renderer.** The visual surface loads Oliver's + fragment through the same sandboxed-WKWebView document assembly as the + preview pane (#230). Nothing in Swift parses markup semantics. +3. **Visual edits are markup ops.** A DOM edit event is translated into a + pure buffer splice — the same posture as the #263 formatting verbs, + which are marker transforms derived from Oliver's documented surface, + never a grammar. +4. **The block map is a conservative sniff, never a parse.** Line shape + and marker prefixes classify blocks, mirroring `ComposeHighlighter`'s + documented posture. When classification is uncertain the block is + non-editable; when source/rendered alignment cannot be *verified*, the + visual surface disables itself and falls back to the plain preview. +5. **Never guess silently.** Unmappable DOM edits are discarded at the next + reconcile (the buffer is truth); the DOM is never treated as authority. + +## The op contract + +### Visual edit event (JS → host) + +The contenteditable surface emits one JSON message per `beforeinput` +(WebKit, `contenteditable="plaintext-only"` so paste and typing are plain +text by construction): + +```json +{ "blockIndex": 3, "inputType": "insertText", + "start": 12, "end": 12, "data": "e" } +``` + +- `blockIndex` — index among the rendered document's block-level children + (marked `data-block` by the host after load; the host owns the numbering). +- `start`/`end` — UTF-16 code-unit offsets **within the block's rendered + text**. For editable blocks (below) rendered text equals the block's + source line text verbatim, so the offsets are buffer-relative after the + block-map translation. Collapsed range = caret. +- `data` — the text the input inserts, if any. +- The event is *not* `preventDefault()`-ed: the browser applies its own + mutation and keeps the caret; the host mirrors the same edit into the + buffer. Drift between the two is corrected at reconcile (below). + +Swift mirror: `ComposeVisualEditEvent` (`Decodable`, pure value). + +### Markup ops (host-side, pure) + +`ComposeMarkupOp` is the closed set of buffer mutations visual editing may +produce. Every op is a value; application is a pure function: + +```swift +enum ComposeMarkupOp: Equatable { + case insertText(String, at: Int) // UTF-16 buffer offset + case replaceText(NSRange, String) // selection replace / paste / IME commit + case deleteText(NSRange) // backspace, forward-delete, cut +} +``` + +- **Derivation** — `ComposeMarkupOp.derive(from:in:blockMap:)` maps a + visual event onto an op or returns `nil` (unmappable): + - `insertText` (with `data`) → insert at caret or replace the extent. + A nil `data` is unmappable — never guess an empty splice. + - **Deliberately unmapped in the spike:** `insertFromPaste` (WebKit + carries the payload on `dataTransfer`, never `data`; reading the + clipboard needs an async hop the op contract does not have), + `insertCompositionText` (mid-IME half-composed text must never + splice), `insertReplacementText`, and `insertTranspose` (spelling- + autoswap semantics we do not model). Each returns `nil` host-side, + and the bridge `preventDefault()`s the DOM side of the same set so + paint never desyncs from the buffer. The buffer stays untouched and + the next reconcile snaps the DOM back to truth. Paste support is a + follow-up card (bridge reads `dataTransfer`, forwards the string as + `data`). + - `deleteContentBackward` / `deleteContentForward` / `deleteByCut` / + `deleteByDrag` with an extent → delete the extent; collapsed → delete + one grapheme before/after the caret (surrogate-pair and ZWJ aware — + never split one). + - Paragraph breaks (`insertParagraphBreak`, `insertLineBreak`) are + **unmappable in the spike** (a visual paragraph split is a block-level + restructure, follow-up card); Enter is also intercepted in JS so the + DOM never produces one. + - Anything unrecognized → `nil`. +- **Application** — `applied(to:)` returns the new buffer and the + resulting caret offset (UTF-16). The caret is remembered for + reconcile-time restoration; the buffer splice flows through + `ComposeDocument.text.didSet` so dirty state, word count, and language + detection behave exactly as if the author typed in the textarea. +- **Marker verbs** (bold, headings, links…) are *designed into* the + contract as the existing `ComposeFormat` transforms — a visual Cmd-B is + `ComposeFormat.apply(.bold, …)` at the block-map-translated range. The + spike ships paragraph text only; marker verbs are the second node-type + card and add no new contract surface. +- **Undo** — spike limitation, owned: the DOM keeps its own undo stack and + the buffer splices do not yet register on a shared one. The contract + requires ops to be values *so that* a shared undo stack can replay them + later; that integration is a follow-up. + +### The block map + +`ComposeBlockMap.compute(source:frontmatterStripped:)` splits the buffer +into block records by line shape — the same prefixes the highlighter +paints: + +| Source shape | Kind | Rendered as | Editable? | +|---|---|---|---| +| doc-start `---`/`+++` … closing fence | `frontmatter` | stripped (policy ≠ none) or passthrough | no | +| `#{1,6} ` line | `heading` | one `hN` | no (spike) | +| maximal run of plain non-blank lines | `paragraph` | one `p` (or soft-break joined) | **yes iff single line and marker-free** | +| maximal run of list-marker lines (`- `/`* `/`+ `/`N. `/`N) `) | `list` | one `ul`/`ol` | no (spike) | +| maximal run of `>` lines | `quote` | one `blockquote` | no (spike) | +| fenced ``` run | `fence` | one `pre` | no | +| indented (tab/4-space) run | `code` | one `pre> & \ { `` (any inline +marker possibility); the render options are text-preserving (smartypants, +wikilinks, and every extension that rewrites characters or block structure +are off — the defaults). This is deliberately over-strict: a paragraph +with an asterisk is simply not visual-editable yet. + +**Alignment is verified, not assumed.** After Oliver renders, the host +counts the DOM's block-level children and compares against the map's +rendered-block count (frontmatter policy shifts the count; the map knows). +On any mismatch the visual surface shows the plain preview and a one-line +notice instead of editing. Misclassified shapes (setext headings, loose +lists) degrade here — visible, honest, never corrupting. + +## The spike surface + +A **Visual** toolbar toggle (Markdown buffers only) switches the preview +pane into visual mode: + +- `ComposeVisualDocument.html(fragment:themeCSS:)` — the #230 document + assembly plus the visual script: `data-block` numbering, per-block + `contenteditable="plaintext-only"` for the editable index set, the + `beforeinput` → message bridge, Enter interception, and + caret/scroll-restore entry points. +- `ComposeVisualEditorView` + coordinator — hosts the WKWebView + (non-persistent store, single-load navigation policy reused from + `ComposePreviewSandbox`), receives the message-handler events, derives + ops, splices the buffer. +- **Reconcile policy** — the visual pane does not live-reload per + keystroke. It re-renders through Oliver after 1.5 s of edit silence or + on blur, then restores the caret (from the last op's result offset, + re-mapped through the fresh block map) and scroll position. Between + reconciles the DOM's own state is provisional; the buffer is truth, so + a reconcile discards exactly the edits that failed to map. +- Save, dirty state, status bar, find bar, and the textarea pane are + unchanged — they all read the same buffer. + +## Boundaries & fallbacks + +- Cooklang/Textile buffers: no visual toggle (Markdown spike only). +- Render options that transform text (smartypants, wikilinks, …): visual + mode stays available but with an empty editable set (read-only surface) + until the options are back to text-preserving. +- Frontmatter present + policy `none`: editable set empty (the passthrough + render shape is not stable to map). +- Any message the host cannot map: no-op on the buffer; next reconcile + snaps the DOM back to truth. +- The visual pane never navigates (same sandbox as #230) and never gains + network/file access (`loadHTMLString`, `baseURL: nil`). + +## Follow-ups (not in this spike) + +1. Marker verbs in visual mode (Cmd-B/I/K) via `ComposeFormat` at mapped + ranges — second node-type card. +2. Headings and lists as editable blocks (prefix-aware text ranges). +3. Visual paragraph splitting (Enter) — needs a block-level op kind + (`splitBlock`), contract extension. +4. Shared undo stack replaying ops across both surfaces. +5. boris-editor upstream: the same op contract expressed over its + SourcePane (filed as an issue draft there, never a PR to boris). + +## Test posture + +- `ComposeBlockMapTests` — classification, editable predicate, frontmatter + policy alignment, alignment-failure detection. +- `ComposeMarkupOpTests` — event → op derivation (insert, replace, + surrogate-pair deletes, unmappables), application + caret results. +- `ComposeVisualDocumentTests` — document assembly: script present, + `` guard inherited, editable gating by index set. +- `ComposeVisualEditorE2ETests` — one real-WKWebView round trip (same + pattern as #230's tests): synthetic `beforeinput` on an editable block + → message → op → buffer contains the edit.