diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa73b13..cb625d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,7 @@ jobs: xcodebuild build \ -project docmostly.xcodeproj \ -scheme docmostly \ - -destination 'platform=iOS Simulator,name=iPad Pro 13-inch (M5),OS=latest' \ + -destination 'generic/platform=iOS Simulator' \ CODE_SIGNING_ALLOWED=NO macos-unit-tests: diff --git a/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift b/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift index 9df2b33..77b232c 100644 --- a/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift +++ b/docmostly/Features/Editor/NativeEditorAttributedAttributes.swift @@ -15,6 +15,16 @@ enum NativeEditorTextColorAttribute: CodableAttributedStringKey { static let name = "docmostly.textColor" } +nonisolated struct NativeEditorLink: Equatable, Hashable, Sendable, Codable { + var href: String + var isInternal: Bool +} + +enum NativeEditorLinkAttribute: CodableAttributedStringKey { + typealias Value = NativeEditorLink + static let name = "docmostly.link" +} + enum NativeEditorCommentIDAttribute: CodableAttributedStringKey { typealias Value = String static let name = "docmostly.commentID" diff --git a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift index 5e8c6fe..90f0c28 100644 --- a/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift +++ b/docmostly/Features/Editor/NativeEditorCommand+Behavior.swift @@ -25,14 +25,168 @@ extension NativeEditorCommand { func matchPriority(query: String) -> Int? { guard query.isEmpty == false else { return 0 } - if title.localizedStandardContains(query) || rawValue.localizedStandardContains(query) { + if title.localizedStandardContainsAtWordStart(query) { return 0 } - if subtitle.localizedStandardContains(query) { + if searchTerms.contains(where: { $0.localizedStandardContains(query) }) { return 1 } + if title.fuzzyMatchesSlashCommandQuery(query) { + return 2 + } + + if rawValue.localizedStandardContains(query) { + return 3 + } + + if subtitle.localizedStandardContains(query) { + return 3 + } + + if title.localizedStandardContains(query) { + return 4 + } + return nil } + + private var searchTerms: [String] { + switch self { + case .paragraph: + ["p", "text", "paragraph"] + case .heading1: + ["title", "big", "large", "h1"] + case .heading2: + ["subtitle", "medium", "h2"] + case .heading3: + ["subtitle", "small", "h3"] + case .bulletedList: + ["unordered", "point", "list"] + case .numberedList: + ["numbered", "ordered", "list", "ol"] + case .todoList: + ["todo", "task", "list", "check", "checkbox"] + case .quote: + ["blockquote", "quotes"] + case .codeBlock: + ["codeblock", "snippet"] + case .image: + ["photo", "picture", "media", "file", "attachment"] + case .video: + ["mp4", "media", "file", "attachment"] + case .audio: + ["music", "sound", "mp3", "media", "file", "attachment"] + case .pdf: + ["document", "embed"] + case .fileAttachment: + ["upload", "csv", "zip"] + case .table: + ["rows", "columns"] + case .baseInline: + ["base", "database", "grid", "spreadsheet"] + case .kanban: + ["board", "cards", "status", "task", "database"] + case .callout: + ["notice", "panel", "info", "warning", "success", "error", "danger"] + case .details: + ["collapsible", "block", "toggle", "details", "expand"] + case .mathInline: + ["math", "inline", "mathinline", "inlinemath", "inline math", "equation", "katex", "latex", "tex"] + case .pageBreak: + ["page", "break", "pagebreak", "print"] + case .divider: + ["horizontal rule", "hr"] + case .columns: + ["layout", "split", "side"] + case .columns3: + ["layout", "split", "triple"] + case .columns4, .columns5: + ["layout", "split"] + case .subpages: + ["child", "children", "nested", "hierarchy", "toc"] + case .syncedBlock: + ["sync", "excerpt", "transclusion", "reusable", "snippet"] + case .embed: + ["url", "external", "iframe"] + case .iframeEmbed: + ["iframe"] + case .airtableEmbed: + ["airtable"] + case .loomEmbed: + ["loom"] + case .figmaEmbed: + ["figma"] + case .typeformEmbed: + ["typeform"] + case .miroEmbed: + ["miro"] + case .youtubeEmbed: + ["youtube", "yt", "media", "video"] + case .vimeoEmbed: + ["vimeo"] + case .framerEmbed: + ["framer"] + case .googleDriveEmbed: + ["google drive", "gdrive"] + case .googleSheetsEmbed: + ["google sheets", "gsheets"] + case .mathBlock: + ["math", "block", "mathblock", "block math", "equation", "katex", "latex", "tex"] + case .mermaid: + ["diagrams", "chart", "uml"] + case .drawio: + ["diagrams", "charts", "uml", "whiteboard"] + case .excalidraw: + ["diagrams", "draw", "sketch", "whiteboard"] + case .date: + ["today"] + case .time: + ["now", "clock"] + case .status: + ["badge", "label", "lozenge"] + case .emoji: + ["icon", "smiley", "emoticon", "symbol", "reaction"] + } + } +} + +private extension String { + func localizedStandardContainsAtWordStart(_ query: String) -> Bool { + guard query.isEmpty == false else { return true } + + var searchStart = startIndex + while searchStart < endIndex { + let searchText = self[searchStart.. Bool { + let normalizedQuery = query.lowercased() + guard normalizedQuery.isEmpty == false else { return true } + + var queryIndex = normalizedQuery.startIndex + for character in lowercased() where character == normalizedQuery[queryIndex] { + queryIndex = normalizedQuery.index(after: queryIndex) + if queryIndex == normalizedQuery.endIndex { + return true + } + } + + return false + } } diff --git a/docmostly/Features/Editor/NativeEditorCommand+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorCommand+RichBlocks.swift index f3ece32..7abba88 100644 --- a/docmostly/Features/Editor/NativeEditorCommand+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorCommand+RichBlocks.swift @@ -102,24 +102,26 @@ extension NativeEditorCommand { return NativeEditorBlock( id: id, kind: .codeBlock(language: "mermaid"), - text: AttributedString("graph TD; A-->B"), + text: AttributedString("flowchart LR\n A --> B"), alignment: .left ) } var defaultTableRows: [NativeEditorTableRow] { - [ - NativeEditorTableRow(cells: [ - NativeEditorTableCell(plainText: "Column 1", isHeader: true, backgroundColorName: nil), - NativeEditorTableCell(plainText: "Column 2", isHeader: true, backgroundColorName: nil) - ]), - NativeEditorTableRow(cells: [ - NativeEditorTableCell(plainText: "", isHeader: false, backgroundColorName: nil), - NativeEditorTableCell(plainText: "", isHeader: false, backgroundColorName: nil) - ]) + let columnCount = 3 + return [ + tableRow(columnCount: columnCount, isHeader: true), + tableRow(columnCount: columnCount, isHeader: false), + tableRow(columnCount: columnCount, isHeader: false) ] } + private func tableRow(columnCount: Int, isHeader: Bool) -> NativeEditorTableRow { + NativeEditorTableRow(cells: (0.. NativeEditorBlock { NativeEditorBlock( id: id, @@ -155,19 +163,16 @@ extension NativeEditorCommand { } private var tableNode: ProseMirrorNode { - ProseMirrorNode(type: "table", content: [ - tableRowNode(cellType: "tableHeader", texts: ["Column 1", "Column 2"]), - tableRowNode(cellType: "tableCell", texts: ["", ""]) - ]) + ProseMirrorNode(type: "table", content: defaultTableRows.map(tableRowNode)) } - private func tableRowNode(cellType: String, texts: [String]) -> ProseMirrorNode { + private func tableRowNode(from row: NativeEditorTableRow) -> ProseMirrorNode { ProseMirrorNode( type: "tableRow", - content: texts.map { text in + content: row.cells.map { cell in ProseMirrorNode( - type: cellType, - content: [paragraphNode(text)] + type: cell.isHeader ? "tableHeader" : "tableCell", + content: [paragraphNode(cell.plainText)] ) } ) diff --git a/docmostly/Features/Editor/NativeEditorCommand+SlashMenu.swift b/docmostly/Features/Editor/NativeEditorCommand+SlashMenu.swift new file mode 100644 index 0000000..b2a585c --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorCommand+SlashMenu.swift @@ -0,0 +1,54 @@ +import Foundation + +extension NativeEditorCommand { + static let slashMenuCases: [NativeEditorCommand] = [ + .paragraph, + .todoList, + .heading1, + .heading2, + .heading3, + .bulletedList, + .numberedList, + .quote, + .codeBlock, + .divider, + .pageBreak, + .image, + .video, + .audio, + .pdf, + .fileAttachment, + .table, + .baseInline, + .kanban, + .details, + .callout, + .mathInline, + .mathBlock, + .mermaid, + .drawio, + .excalidraw, + .date, + .time, + .status, + .emoji, + .subpages, + .syncedBlock, + .columns, + .columns3, + .columns4, + .columns5, + .embed, + .iframeEmbed, + .airtableEmbed, + .loomEmbed, + .figmaEmbed, + .typeformEmbed, + .miroEmbed, + .youtubeEmbed, + .vimeoEmbed, + .framerEmbed, + .googleDriveEmbed, + .googleSheetsEmbed + ] +} diff --git a/docmostly/Features/Editor/NativeEditorCommand.swift b/docmostly/Features/Editor/NativeEditorCommand.swift index cbb1cf4..e027aa3 100644 --- a/docmostly/Features/Editor/NativeEditorCommand.swift +++ b/docmostly/Features/Editor/NativeEditorCommand.swift @@ -104,7 +104,7 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { var title: String { switch self { case .paragraph: - "Paragraph" + "Text" case .heading1: "Heading 1" case .heading2: @@ -112,15 +112,15 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { case .heading3: "Heading 3" case .bulletedList: - "Bulleted List" + "Bullet list" case .numberedList: - "Numbered List" + "Numbered list" case .todoList: - "To-do List" + "To-do list" case .quote: "Quote" case .codeBlock: - "Code Block" + "Code" case .image: "Image" case .video: @@ -128,9 +128,9 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { case .audio: "Audio" case .pdf: - "PDF" + "Embed PDF" case .fileAttachment: - "File" + "File attachment" case .table: "Table" case .baseInline: @@ -140,11 +140,11 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { case .callout: "Callout" case .details: - "Details" + "Toggle block" case .mathInline: - "Math Inline" + "Math inline" case .pageBreak: - "Page Break" + "Page break" case .divider: "Divider" case .columns: @@ -156,9 +156,9 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { case .columns5: "5 Columns" case .subpages: - "Subpages" + "Subpages (Child pages)" case .syncedBlock: - "Synced Block" + "Synced block" case .embed: "Embed" case .iframeEmbed: @@ -184,13 +184,13 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { case .googleSheetsEmbed: "Google Sheets" case .mathBlock: - "Math Block" + "Math block" case .mermaid: - "Mermaid" + "Mermaid diagram" case .drawio: - "Draw.io" + "Draw.io (diagrams.net)" case .excalidraw: - "Excalidraw" + "Excalidraw (Whiteboard)" case .date: "Date" case .time: @@ -229,9 +229,9 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { case .audio: "Audio placeholder" case .pdf: - "PDF placeholder" + "Upload and embed a PDF file" case .fileAttachment: - "File attachment placeholder" + "Upload any file from your device" case .table: "Two-column table" case .baseInline: @@ -241,7 +241,7 @@ enum NativeEditorCommand: String, CaseIterable, Identifiable { case .callout: "Highlighted note" case .details: - "Collapsible detail section" + "Insert collapsible block" case .mathInline: "Inline equation" case .pageBreak: diff --git a/docmostly/Features/Editor/NativeEditorDocument+Blockquotes.swift b/docmostly/Features/Editor/NativeEditorDocument+Blockquotes.swift new file mode 100644 index 0000000..4d6ee70 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorDocument+Blockquotes.swift @@ -0,0 +1,62 @@ +import Foundation + +nonisolated extension NativeEditorDocument { + static func blockquoteNode(from block: NativeEditorBlock) -> ProseMirrorNode { + let textNode = blockquoteTextContainerNode(from: block) + guard block.rawNode?.type == "blockquote" else { + return ProseMirrorNode(type: "blockquote", content: [textNode]) + } + + var content = block.rawNode?.content ?? [] + if let textIndex = content.firstIndex(where: isBlockquoteTextContainer) { + content[textIndex] = textNode + } else { + content.insert(textNode, at: content.startIndex) + } + + let attrs = block.rawNode?.attrs ?? [:] + return ProseMirrorNode( + type: "blockquote", + attrs: attrs.isEmpty ? nil : attrs, + content: content + ) + } + + private static func blockquoteTextContainerNode(from block: NativeEditorBlock) -> ProseMirrorNode { + guard block.rawNode?.type == "blockquote" else { + return textContainerNode(type: "paragraph", block: block) + } + + let originalNode = block.rawNode?.content?.first(where: isBlockquoteTextContainer) + let nodeType = originalNode?.type ?? "paragraph" + var attrs = originalNode?.attrs ?? [:] + + if let alignment = block.alignment.proseMirrorValue { + attrs["textAlign"] = alignment + } else { + attrs.removeValue(forKey: "textAlign") + } + + return ProseMirrorNode( + type: nodeType, + attrs: attrs.isEmpty ? nil : attrs, + content: blockquoteTextContainerContent(type: nodeType, block: block) + ) + } + + private static func blockquoteTextContainerContent( + type: String, + block: NativeEditorBlock + ) -> [ProseMirrorNode] { + if type == "codeBlock" { + let text = String(block.text.characters) + return text.isEmpty ? [] : [ProseMirrorNode(type: "text", text: text)] + } + + return block.inlineContent.map(inlineNodes(from:)) ?? inlineNodes(from: block.text) + } + + private static func isBlockquoteTextContainer(_ node: ProseMirrorNode) -> Bool { + node.type == "paragraph" || node.type == "heading" || node.type == "codeBlock" + } +} diff --git a/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift b/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift index bd5b33e..03632ba 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Decoding.swift @@ -16,13 +16,15 @@ nonisolated extension NativeEditorDocument { static func textBlock(kind: NativeEditorBlockKind, node: ProseMirrorNode) -> NativeEditorBlock { let inlineContent = inlineContent(from: node.content ?? []) let needsRawPreservation = inlineContent.contains(where: \.requiresRawPreservation) + let needsAttrPreservation = editableAttrsNeedRawPreservation(kind: kind, node: node) + let rawNode = needsRawPreservation || needsAttrPreservation ? node : nil return NativeEditorBlock( kind: kind, text: attributedText(from: inlineContent), alignment: NativeEditorTextAlignment(attrs: node.attrs), inlineContent: needsRawPreservation ? inlineContent : nil, - rawNode: needsRawPreservation ? node : nil + rawNode: rawNode ) } @@ -60,6 +62,25 @@ nonisolated extension NativeEditorDocument { } } + private static func editableAttrsNeedRawPreservation( + kind: NativeEditorBlockKind, + node: ProseMirrorNode + ) -> Bool { + guard let attrs = node.attrs, attrs.isEmpty == false else { return false } + + let modeledKeys: Set + switch kind { + case .paragraph: + modeledKeys = ["textAlign"] + case .heading: + modeledKeys = ["level", "textAlign"] + default: + return false + } + + return attrs.keys.contains { modeledKeys.contains($0) == false } + } + private static func editableBlocks(from node: ProseMirrorNode) -> [NativeEditorBlock]? { if let singleBlock = singleEditableBlock(from: node) { return [singleBlock] @@ -117,9 +138,14 @@ nonisolated extension NativeEditorDocument { item: ProseMirrorNode, indentLevel: Int ) -> [NativeEditorBlock] { - var block = textBlock(kind: kind, node: firstTextContainer(in: item) ?? item) + let textContainer = firstTextContainer(in: item) ?? item + var block = textBlock(kind: kind, node: textContainer) block.indentLevel = indentLevel + if listItemNeedsRawPreservation(kind: kind, item: item) { + block.rawNode = item + } + let nestedBlocks: [NativeEditorBlock] = (item.content ?? []).flatMap { child in if child.isListContainer { listBlocks(from: child, indentLevel: indentLevel + 1) @@ -131,6 +157,32 @@ nonisolated extension NativeEditorDocument { return [block] + nestedBlocks } + private static func listItemNeedsRawPreservation( + kind: NativeEditorBlockKind, + item: ProseMirrorNode + ) -> Bool { + listItemAttrsNeedRawPreservation(kind: kind, item: item) || + listItemHasAdditionalNonListContent(item) + } + + private static func listItemAttrsNeedRawPreservation( + kind: NativeEditorBlockKind, + item: ProseMirrorNode + ) -> Bool { + guard let attrs = item.attrs, attrs.isEmpty == false else { return false } + + if case .taskListItem = kind { + return attrs.keys.contains { $0 != "checked" } + } + + return true + } + + private static func listItemHasAdditionalNonListContent(_ item: ProseMirrorNode) -> Bool { + let nonListContent = (item.content ?? []).filter { $0.isListContainer == false } + return nonListContent.count > 1 + } + private static func orderedListOrdinal(start: Int, offset: Int) -> Int { let result = start.addingReportingOverflow(offset) return result.overflow ? Int.max : result.partialValue @@ -143,18 +195,40 @@ nonisolated extension NativeEditorDocument { case "heading": textBlock(kind: .heading(level: node.attrs?["level"]?.intValue ?? 1), node: node) case "blockquote": - textBlock(kind: .blockquote, node: firstTextContainer(in: node) ?? node) + blockquoteBlock(from: node) case "codeBlock": NativeEditorBlock( kind: .codeBlock(language: node.attrs?["language"]?.stringValue), text: AttributedString(plainText(in: node.content ?? [])), - alignment: .left + alignment: .left, + rawNode: codeBlockAttrsNeedRawPreservation(node) ? node : nil ) default: nil } } + private static func blockquoteBlock(from node: ProseMirrorNode) -> NativeEditorBlock { + var block = textBlock(kind: .blockquote, node: firstTextContainer(in: node) ?? node) + if blockquoteNeedsRawPreservation(node) { + block.rawNode = node + } + return block + } + + private static func blockquoteNeedsRawPreservation(_ node: ProseMirrorNode) -> Bool { + if let attrs = node.attrs, attrs.isEmpty == false { + return true + } + + return (node.content ?? []).count != 1 + } + + private static func codeBlockAttrsNeedRawPreservation(_ node: ProseMirrorNode) -> Bool { + guard let attrs = node.attrs, attrs.isEmpty == false else { return false } + return attrs.keys.contains { $0 != "language" } + } + private static func richBlock(from node: ProseMirrorNode) -> NativeEditorBlock? { if let mediaBlock = mediaRichBlock(from: node) { return mediaBlock diff --git a/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift b/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift index 73677dd..0f44465 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Encoding.swift @@ -31,9 +31,15 @@ nonisolated extension NativeEditorDocument { block: NativeEditorBlock, attrs: [String: ProseMirrorJSONValue] = [:] ) -> ProseMirrorNode { - var mergedAttrs = attrs + var mergedAttrs = preservedEditableAttrs(type: type, block: block) + for attr in attrs { + mergedAttrs[attr.key] = attr.value + } + if let alignment = block.alignment.proseMirrorValue { mergedAttrs["textAlign"] = alignment + } else { + mergedAttrs.removeValue(forKey: "textAlign") } return ProseMirrorNode( @@ -43,6 +49,14 @@ nonisolated extension NativeEditorDocument { ) } + private static func preservedEditableAttrs( + type: String, + block: NativeEditorBlock + ) -> [String: ProseMirrorJSONValue] { + guard block.rawNode?.type == type else { return [:] } + return block.rawNode?.attrs ?? [:] + } + static func inlineNodes(from attributedText: AttributedString) -> [ProseMirrorNode] { var nodes: [ProseMirrorNode] = [] @@ -133,7 +147,11 @@ nonisolated extension NativeEditorDocument { private static func append(_ nestedList: ProseMirrorNode, toLastItemIn itemNodes: inout [ProseMirrorNode]) { guard var lastItem = itemNodes.popLast() else { return } var content = lastItem.content ?? [] - content.append(nestedList) + if let nestedIndex = content.firstIndex(where: \.isListContainer) { + content[nestedIndex] = nestedList + } else { + content.append(nestedList) + } lastItem.content = content itemNodes.append(lastItem) } @@ -166,7 +184,7 @@ nonisolated extension NativeEditorDocument { case .heading(let level): textContainerNode(type: "heading", block: block, attrs: ["level": .int(level)]) case .blockquote: - ProseMirrorNode(type: "blockquote", content: [textContainerNode(type: "paragraph", block: block)]) + blockquoteNode(from: block) case .codeBlock(let language): codeBlockNode(language: language, block: block) case .bulletListItem, .orderedListItem, .taskListItem: @@ -177,13 +195,25 @@ nonisolated extension NativeEditorDocument { } private static func codeBlockNode(language: String?, block: NativeEditorBlock) -> ProseMirrorNode { - ProseMirrorNode( + var attrs = preservedCodeBlockAttrs(from: block) + if let language { + attrs["language"] = .string(language) + } else { + attrs.removeValue(forKey: "language") + } + + return ProseMirrorNode( type: "codeBlock", - attrs: language.map { ["language": .string($0)] }, + attrs: attrs.isEmpty ? nil : attrs, content: plainTextNodes(from: String(block.text.characters)) ) } + private static func preservedCodeBlockAttrs(from block: NativeEditorBlock) -> [String: ProseMirrorJSONValue] { + guard block.rawNode?.type == "codeBlock" else { return [:] } + return block.rawNode?.attrs ?? [:] + } + private static func richFallbackNode(from block: NativeEditorBlock) -> ProseMirrorNode { if let mediaNode = mediaFallbackNode(from: block) { return mediaNode @@ -217,10 +247,10 @@ nonisolated extension NativeEditorDocument { private static func structuralFallbackNode(from block: NativeEditorBlock) -> ProseMirrorNode? { switch block.kind { - case .callout: - ProseMirrorNode(type: "callout", content: [textContainerNode(type: "paragraph", block: block)]) - case .details: - ProseMirrorNode(type: "details") + case .callout(let callout): + NativeEditorRichBlockNodeFactory.calloutNode(from: callout) + case .details(let details): + NativeEditorRichBlockNodeFactory.detailsNode(from: details) case .pageBreak: ProseMirrorNode(type: "pageBreak") case .divider: @@ -258,17 +288,105 @@ nonisolated extension NativeEditorDocument { } private static func listItemNode(from block: NativeEditorBlock) -> ProseMirrorNode { - ProseMirrorNode(type: "listItem", content: [textContainerNode(type: "paragraph", block: block)]) + ProseMirrorNode( + type: "listItem", + attrs: preservedListItemAttrs(type: "listItem", block: block), + content: listItemContent(type: "listItem", block: block) + ) } private static func taskItemNode(from block: NativeEditorBlock) -> ProseMirrorNode { ProseMirrorNode( type: "taskItem", - attrs: ["checked": .bool(taskItemCheckedState(from: block))], - content: [textContainerNode(type: "paragraph", block: block)] + attrs: taskItemAttrs(from: block), + content: listItemContent(type: "taskItem", block: block) + ) + } + + private static func preservedListItemAttrs( + type: String, + block: NativeEditorBlock + ) -> [String: ProseMirrorJSONValue]? { + guard block.rawNode?.type == type else { return nil } + let attrs = block.rawNode?.attrs ?? [:] + return attrs.isEmpty ? nil : attrs + } + + private static func taskItemAttrs(from block: NativeEditorBlock) -> [String: ProseMirrorJSONValue] { + var attrs = block.rawNode?.type == "taskItem" ? block.rawNode?.attrs ?? [:] : [:] + attrs["checked"] = .bool(taskItemCheckedState(from: block)) + return attrs + } + + private static func listItemContent( + type: String, + block: NativeEditorBlock + ) -> [ProseMirrorNode] { + let textNode = listItemTextContainerNode(type: type, block: block) + guard block.rawNode?.type == type, var content = block.rawNode?.content else { + return [textNode] + } + + if let textIndex = content.firstIndex(where: isListItemTextContainer) { + content[textIndex] = textNode + } else { + content.insert(textNode, at: content.startIndex) + } + + return content + } + + private static func listItemTextContainerNode( + type: String, + block: NativeEditorBlock + ) -> ProseMirrorNode { + let originalNode = originalListItemTextContainer(type: type, block: block) + let nodeType = originalNode?.type ?? "paragraph" + var attrs = originalNode?.attrs ?? [:] + + if let alignment = block.alignment.proseMirrorValue { + attrs["textAlign"] = alignment + } else { + attrs.removeValue(forKey: "textAlign") + } + + return ProseMirrorNode( + type: nodeType, + attrs: attrs.isEmpty ? nil : attrs, + content: listItemTextContainerContent(type: nodeType, block: block) ) } + private static func originalListItemTextContainer( + type: String, + block: NativeEditorBlock + ) -> ProseMirrorNode? { + if block.rawNode?.type == type { + return block.rawNode?.content?.first(where: isListItemTextContainer) + } + + if let rawNode = block.rawNode, isListItemTextContainer(rawNode) { + return rawNode + } + + return nil + } + + private static func listItemTextContainerContent( + type: String, + block: NativeEditorBlock + ) -> [ProseMirrorNode] { + if type == "codeBlock" { + return plainTextNodes(from: String(block.text.characters)) + } + + return block.inlineContent.map(inlineNodes(from:)) ?? inlineNodes(from: block.text) + } + + private static func isListItemTextContainer(_ node: ProseMirrorNode) -> Bool { + node.type == "paragraph" || node.type == "heading" || node.type == "codeBlock" + } + private static func taskItemCheckedState(from block: NativeEditorBlock) -> Bool { if case .taskListItem(let isChecked) = block.kind { return isChecked @@ -346,24 +464,30 @@ nonisolated extension NativeEditorDocument { private static func inlineAtom(from run: AttributedString.Runs.Run) -> InlineAtomEncoding? { if let mention = run[NativeEditorMentionAttribute.self] { + var node = ProseMirrorNode(type: "mention", attrs: attrs(from: mention)) + node.marks = atomMarks(from: run, presentationMarkType: nil) return InlineAtomEncoding( displayText: mention.displayText, - node: ProseMirrorNode(type: "mention", attrs: attrs(from: mention)) + node: node ) } if let status = run[NativeEditorStatusAttribute.self] { + var node = ProseMirrorNode(type: "status", attrs: statusAttrs(from: status)) + node.marks = atomMarks(from: run, presentationMarkType: "bold") return InlineAtomEncoding( displayText: status.text, - node: ProseMirrorNode(type: "status", attrs: statusAttrs(from: status)), + node: node, presentationMarkType: "bold" ) } if let math = run[NativeEditorMathInlineAttribute.self] { + var node = ProseMirrorNode(type: "mathInline", attrs: ["text": .string(math.text)]) + node.marks = atomMarks(from: run, presentationMarkType: "code") return InlineAtomEncoding( displayText: math.text, - node: ProseMirrorNode(type: "mathInline", attrs: ["text": .string(math.text)]), + node: node, presentationMarkType: "code" ) } @@ -371,6 +495,19 @@ nonisolated extension NativeEditorDocument { return nil } + private static func atomMarks( + from run: AttributedString.Runs.Run, + presentationMarkType: String? + ) -> [ProseMirrorMark]? { + guard var marks = marks(from: run) else { return nil } + + if let presentationMarkType { + marks.removeAll { $0.type == presentationMarkType } + } + + return marks.isEmpty ? nil : marks + } + private static func marksForTextSurroundingAtom( from run: AttributedString.Runs.Run, atom: InlineAtomEncoding @@ -390,12 +527,24 @@ nonisolated extension NativeEditorDocument { ProseMirrorNode(type: "text", marks: proseMirrorMarks(from: marks), text: text) case .hardBreak: ProseMirrorNode(type: "hardBreak") - case .mention(let mention): - ProseMirrorNode(type: "mention", attrs: attrs(from: mention)) - case .status(let status): - ProseMirrorNode(type: "status", attrs: statusAttrs(from: status)) - case .mathInline(let math): - ProseMirrorNode(type: "mathInline", attrs: ["text": .string(math.text)]) + case .mention(let mention, let marks): + ProseMirrorNode( + type: "mention", + attrs: attrs(from: mention), + marks: proseMirrorMarks(from: marks) + ) + case .status(let status, let marks): + ProseMirrorNode( + type: "status", + attrs: statusAttrs(from: status), + marks: proseMirrorMarks(from: marks) + ) + case .mathInline(let math, let marks): + ProseMirrorNode( + type: "mathInline", + attrs: ["text": .string(math.text)], + marks: proseMirrorMarks(from: marks) + ) case .unsupported(let node): node } diff --git a/docmostly/Features/Editor/NativeEditorDocument+InlineDecoding.swift b/docmostly/Features/Editor/NativeEditorDocument+InlineDecoding.swift index c31a765..87b48bb 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+InlineDecoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+InlineDecoding.swift @@ -8,11 +8,14 @@ nonisolated extension NativeEditorDocument { case "hardBreak": [.hardBreak] case "mention": - [.mention(mention(from: node))] + [.mention(mention(from: node), marks: textMarks(from: node.marks ?? []))] case "status": - [.status(statusBadge(from: node))] + [.status(statusBadge(from: node), marks: textMarks(from: node.marks ?? []))] case "mathInline": - [.mathInline(NativeEditorMathInline(text: node.attrs?["text"]?.stringValue ?? ""))] + [.mathInline( + NativeEditorMathInline(text: node.attrs?["text"]?.stringValue ?? ""), + marks: textMarks(from: node.marks ?? []) + )] default: nestedInlineContent(from: node) } @@ -34,19 +37,22 @@ nonisolated extension NativeEditorDocument { return segment case .hardBreak: return AttributedString("\n") - case .mention(let mention): + case .mention(let mention, let marks): var segment = AttributedString(mention.displayText) segment[NativeEditorMentionAttribute.self] = mention segment.foregroundColor = DocmostlyTheme.primary + apply(marks, to: &segment) return segment - case .status(let status): + case .status(let status, let marks): var segment = AttributedString(status.text) segment[NativeEditorStatusAttribute.self] = status + apply(marks, to: &segment) return segment - case .mathInline(let math): + case .mathInline(let math, let marks): var segment = AttributedString(math.text) segment[NativeEditorMathInlineAttribute.self] = math segment.inlinePresentationIntent = .code + apply(marks, to: &segment) return segment case .unsupported(let node): return AttributedString(node.text ?? "") @@ -89,7 +95,10 @@ nonisolated extension NativeEditorDocument { static func richTextMark(from mark: ProseMirrorMark) -> NativeEditorTextMark { switch mark.type { case "link": - .link(href: mark.attrs?["href"]?.stringValue ?? "") + .link( + href: mark.attrs?["href"]?.stringValue ?? "", + isInternal: mark.attrs?["internal"]?.boolValue ?? false + ) case "highlight": .highlight( color: mark.attrs?["color"]?.stringValue, @@ -148,8 +157,8 @@ nonisolated extension NativeEditorDocument { switch mark { case .underline: text.underlineStyle = .single - case .link(let href): - text.link = Self.safeLinkURL(from: href) + case .link(let href, let isInternal): + applyLinkMark(href: href, isInternal: isInternal, to: &text) case .highlight(let color, let colorName): if let color { text[NativeEditorHighlightColorAttribute.self] = color @@ -178,6 +187,12 @@ nonisolated extension NativeEditorDocument { } } + static func applyLinkMark(href: String, isInternal: Bool, to text: inout AttributedString) { + guard let link = preservedLink(href: href, isInternal: isInternal) else { return } + text[NativeEditorLinkAttribute.self] = link + text.link = safeLinkURL(from: href) + } + static func safeLinkURL(from href: String) -> URL? { guard let url = URL(string: href) else { return nil } let allowedSchemes = ["https", "http", "mailto"] @@ -185,6 +200,16 @@ nonisolated extension NativeEditorDocument { return allowedSchemes.contains(scheme) ? url : nil } + static func preservedLink(href: String, isInternal: Bool = false) -> NativeEditorLink? { + guard href.isEmpty == false else { return nil } + + if safeLinkURL(from: href) != nil || href.hasPrefix("/") || href.hasPrefix("#") { + return NativeEditorLink(href: href, isInternal: isInternal) + } + + return nil + } + static func insertPresentationIntent( _ presentationIntent: InlinePresentationIntent, into text: inout AttributedString diff --git a/docmostly/Features/Editor/NativeEditorDocument+InlineEncoding.swift b/docmostly/Features/Editor/NativeEditorDocument+InlineEncoding.swift index 11747e8..4a88bb0 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+InlineEncoding.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+InlineEncoding.swift @@ -74,7 +74,9 @@ nonisolated extension NativeEditorDocument { from run: AttributedString.Runs.Run, to marks: inout [ProseMirrorMark] ) { - if let href = run.link?.absoluteString { + if let link = run[NativeEditorLinkAttribute.self] { + appendMarkIfMissing(linkProseMirrorMark(href: link.href, isInternal: link.isInternal), to: &marks) + } else if let href = run.link?.absoluteString { appendMarkIfMissing(ProseMirrorMark(type: "link", attrs: ["href": .string(href)]), to: &marks) } } @@ -144,8 +146,8 @@ nonisolated extension NativeEditorDocument { private static func richProseMirrorMark(from mark: NativeEditorTextMark) -> ProseMirrorMark { switch mark { - case .link(let href): - ProseMirrorMark(type: "link", attrs: ["href": .string(href)]) + case .link(let href, let isInternal): + linkProseMirrorMark(href: href, isInternal: isInternal) case .highlight(let color, let colorName): ProseMirrorMark(type: "highlight", attrs: optionalAttrs(markAttrs(color, colorName))) case .textColor(let color): @@ -159,6 +161,14 @@ nonisolated extension NativeEditorDocument { } } + private static func linkProseMirrorMark(href: String, isInternal: Bool) -> ProseMirrorMark { + var attrs: [String: ProseMirrorJSONValue] = ["href": .string(href)] + if isInternal { + attrs["internal"] = .bool(true) + } + return ProseMirrorMark(type: "link", attrs: attrs) + } + private static func markAttrs(_ color: String?, _ colorName: String?) -> [String: String?] { [ "color": color, diff --git a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift index b72bb91..32e11e3 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift @@ -70,13 +70,15 @@ nonisolated extension NativeEditorDocument { static func columnsBlock(from node: ProseMirrorNode) -> NativeEditorColumnsBlock { let columns = (node.content ?? []).filter { $0.type == "column" } let columnTexts = columns.map { plainText(in: $0.content ?? []) } + let columnWidths = columns.map { columnWidth(from: $0.attrs) } return NativeEditorColumnsBlock( layout: node.attrs?["layout"]?.stringValue ?? "two_equal", widthMode: node.attrs?["widthMode"]?.stringValue ?? "normal", columnCount: columns.count, previewText: columnTexts.joined(separator: " "), - columnTexts: columnTexts + columnTexts: columnTexts, + columnWidths: columnWidths ) } @@ -162,9 +164,15 @@ nonisolated extension NativeEditorDocument { .prefix(NativeEditorTable.maximumColumnCount) .map { cell in let columnWidths = tableColumnWidths(from: cell.attrs) + let cellContent = cell.content ?? [] + let inlineContent = inlineContent(from: cellContent) return NativeEditorTableCell( - plainText: plainText(in: cell.content ?? []), + plainText: inlineContent.plainText, + inlineContent: inlineContent.preservedForTableCell, + preservedContent: preservedTableCellContent(from: cellContent, inlineContent: inlineContent), isHeader: cell.type == "tableHeader", + textAlignment: tableCellTextAlignment(from: cellContent), + backgroundColor: cell.attrs?["backgroundColor"]?.stringValue, backgroundColorName: cell.attrs?["backgroundColorName"]?.stringValue, columnWidth: columnWidths.first, columnSpan: normalizedTableSpan(cell.attrs?["colspan"]?.intValue), @@ -174,6 +182,39 @@ nonisolated extension NativeEditorDocument { } } + private static func tableCellTextAlignment(from content: [ProseMirrorNode]) -> NativeEditorTextAlignment? { + guard + let firstNode = content.first, + firstNode.type == "paragraph", + let attrs = firstNode.attrs, + attrs.keys.contains("textAlign") + else { + return nil + } + + return NativeEditorTextAlignment(attrs: attrs) + } + + private static func preservedTableCellContent( + from content: [ProseMirrorNode], + inlineContent: [NativeEditorInlineContent] + ) -> [ProseMirrorNode]? { + guard content.isEmpty == false else { return nil } + + let hasBlockContent = content.count != 1 || content.first?.type != "paragraph" + let hasParagraphAttrs = content.first?.type == "paragraph" && + content.first?.attrs?.isEmpty == false + let hasUnsupportedInlineContent = inlineContent.contains { item in + if case .unsupported = item { + return true + } + + return false + } + + return hasBlockContent || hasParagraphAttrs || hasUnsupportedInlineContent ? content : nil + } + private static func tableColumnWidths(from attrs: [String: ProseMirrorJSONValue]?) -> [Int] { guard let value = attrs?["colwidth"] ?? attrs?["colWidth"] else { return [] @@ -187,6 +228,21 @@ nonisolated extension NativeEditorDocument { } } + private static func columnWidth(from attrs: [String: ProseMirrorJSONValue]?) -> Double? { + guard let value = attrs?["width"] else { return nil } + + switch value { + case .int(let width): + return Double(width) + case .double(let width): + return width + case .string(let width): + return Double(width) + case .bool, .object, .array, .null: + return nil + } + } + private static func normalizedTableSpan(_ value: Int?) -> Int { max(value ?? 1, 1) } diff --git a/docmostly/Features/Editor/NativeEditorInlineContent.swift b/docmostly/Features/Editor/NativeEditorInlineContent.swift index 630da3c..6decb8a 100644 --- a/docmostly/Features/Editor/NativeEditorInlineContent.swift +++ b/docmostly/Features/Editor/NativeEditorInlineContent.swift @@ -3,9 +3,9 @@ import Foundation nonisolated enum NativeEditorInlineContent: Equatable, Hashable, Sendable, Codable { case text(String, marks: [NativeEditorTextMark]) case hardBreak - case mention(NativeEditorMention) - case status(NativeEditorStatusBadge) - case mathInline(NativeEditorMathInline) + case mention(NativeEditorMention, marks: [NativeEditorTextMark] = []) + case status(NativeEditorStatusBadge, marks: [NativeEditorTextMark] = []) + case mathInline(NativeEditorMathInline, marks: [NativeEditorTextMark] = []) case unsupported(ProseMirrorNode) var plainText: String { @@ -14,11 +14,11 @@ nonisolated enum NativeEditorInlineContent: Equatable, Hashable, Sendable, Codab text case .hardBreak: "\n" - case .mention(let mention): + case .mention(let mention, _): mention.displayText - case .status(let status): + case .status(let status, _): status.text - case .mathInline(let math): + case .mathInline(let math, _): math.text case .unsupported(let node): node.text ?? "" @@ -40,12 +40,39 @@ nonisolated enum NativeEditorInlineContent: Equatable, Hashable, Sendable, Codab } case .hardBreak: false - case .mention, .status, .mathInline: - false + case .mention(_, let marks), .status(_, let marks), .mathInline(_, let marks): + marks.contains { mark in + if case .unknown = mark { + return true + } + + return false + } case .unsupported: true } } + + var requiresTableCellInlinePreservation: Bool { + switch self { + case .text(_, let marks): + marks.isEmpty == false + case .hardBreak: + false + case .mention, .status, .mathInline, .unsupported: + true + } + } +} + +nonisolated extension Array where Element == NativeEditorInlineContent { + var plainText: String { + map(\.plainText).joined() + } + + var preservedForTableCell: [NativeEditorInlineContent]? { + contains(where: \.requiresTableCellInlinePreservation) ? self : nil + } } nonisolated enum NativeEditorTextMark: Equatable, Hashable, Sendable, Codable { @@ -54,7 +81,7 @@ nonisolated enum NativeEditorTextMark: Equatable, Hashable, Sendable, Codable { case underline case strikethrough case code - case link(href: String) + case link(href: String, isInternal: Bool = false) case highlight(color: String?, colorName: String?) case textColor(String) case `subscript` diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Blockquotes.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Blockquotes.swift new file mode 100644 index 0000000..0b95c55 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Blockquotes.swift @@ -0,0 +1,44 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func blockquoteBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + var quoteLines: [String] = [] + var currentIndex = index + + while currentIndex < lines.endIndex, + let text = blockquoteLineText(from: lines[currentIndex]) { + quoteLines.append(text) + currentIndex = lines.index(after: currentIndex) + } + + guard quoteLines.count > 1 else { return nil } + + return ( + NativeEditorBlock( + kind: .blockquote, + text: multilineParagraphText(from: quoteLines), + alignment: .left + ), + currentIndex + ) + } + + static func blockquoteMarkdown(from text: String) -> String { + text.split(separator: "\n", omittingEmptySubsequences: false) + .map { "> \($0)" } + .joined(separator: "\n") + } + + private static func blockquoteLineText(from line: String) -> String? { + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + if trimmedLine == ">" { + return "" + } + + guard trimmedLine.hasPrefix("> ") else { return nil } + return String(trimmedLine.dropFirst(2)) + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift new file mode 100644 index 0000000..9332f26 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Columns.swift @@ -0,0 +1,169 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func columnsHTMLBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + guard let columnsAttributes = divAttributes(from: lines[index], dataType: "columns") else { + return nil + } + + var columns: [ParsedColumnHTML] = [] + var currentIndex = lines.index(after: index) + + while currentIndex < lines.endIndex { + let line = lines[currentIndex].trimmingCharacters(in: .whitespacesAndNewlines) + if line.localizedCaseInsensitiveCompare("") == .orderedSame { + guard columns.isEmpty == false else { return nil } + let columnsBlock = nativeColumnsBlock(from: columnsAttributes, columns: columns) + return ( + NativeEditorBlock( + kind: .columns(columnsBlock), + text: AttributedString(columnsBlock.previewText), + alignment: .left, + rawNode: NativeEditorRichBlockNodeFactory.columnsNode(from: columnsBlock) + ), + lines.index(after: currentIndex) + ) + } + + guard let column = parsedColumnHTML(in: lines, startingAt: currentIndex) else { + return nil + } + columns.append(column.column) + currentIndex = column.endIndex + } + + return nil + } + + static func columnsMarkdown(from columns: NativeEditorColumnsBlock) -> String { + let columnTexts = columns.normalizedColumnTexts + let columnWidths = columns.normalizedColumnWidths + let widthMode = columns.widthMode.isEmpty ? "normal" : columns.widthMode + let widthModeAttribute = widthMode == "normal" + ? "" + : #" data-width-mode="\#(escapedInlineHTMLAttribute(widthMode))""# + let layout = escapedInlineHTMLAttribute(columns.layout) + let openingTag = #"
"# + let columnMarkup = zip(columnTexts, columnWidths).map { text, width in + columnMarkdown(text: text, width: width ?? 1) + }.joined(separator: "\n") + + return """ + \(openingTag) + \(columnMarkup) +
+ """ + } + + private struct ParsedColumnHTML { + var text: String + var width: Double? + } + + private static func parsedColumnHTML( + in lines: [String], + startingAt index: Array.Index + ) -> (column: ParsedColumnHTML, endIndex: Array.Index)? { + guard let columnAttributes = divAttributes(from: lines[index], dataType: "column") else { + return nil + } + + var bodyLines: [String] = [] + var currentIndex = lines.index(after: index) + + while currentIndex < lines.endIndex { + let line = lines[currentIndex].trimmingCharacters(in: .whitespacesAndNewlines) + if line.localizedCaseInsensitiveCompare("") == .orderedSame { + return ( + ParsedColumnHTML( + text: columnText(from: bodyLines), + width: columnAttributes["data-width"].flatMap(Double.init) + ), + lines.index(after: currentIndex) + ) + } + + bodyLines.append(lines[currentIndex]) + currentIndex = lines.index(after: currentIndex) + } + + return nil + } + + private static func nativeColumnsBlock( + from attributes: [String: String], + columns: [ParsedColumnHTML] + ) -> NativeEditorColumnsBlock { + let columnTexts = columns.map(\.text) + let columnWidths = columns.map(\.width) + return NativeEditorColumnsBlock( + layout: nonEmptyAttribute(attributes["data-layout"]) ?? "two_equal", + widthMode: nonEmptyAttribute(attributes["data-width-mode"]) ?? "normal", + columnCount: columnTexts.count, + previewText: columnTexts.joined(separator: " "), + columnTexts: columnTexts, + columnWidths: columnWidths + ) + } + + private static func divAttributes(from line: String, dataType: String) -> [String: String]? { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedLine.lowercased().hasPrefix(""), tagEnd > trimmedLine.startIndex else { + return nil + } + + let openingTag = String(trimmedLine[trimmedLine.startIndex.. String { + lines.compactMap(columnTextLine(from:)) + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func columnTextLine(from line: String) -> String? { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedLine.isEmpty == false else { return nil } + + let lowercasedLine = trimmedLine.lowercased() + if lowercasedLine.hasPrefix(""), + let openingEnd = trimmedLine.firstIndex(of: ">"), + let closingRange = trimmedLine.range(of: "

", options: [.caseInsensitive, .backwards]) { + let contentStart = trimmedLine.index(after: openingEnd) + return unescapedInlineHTMLText(String(trimmedLine[contentStart.. String { + let widthText = htmlNumber(width) + return """ +
+ \(escapedInlineHTMLText(text.trimmingCharacters(in: .whitespacesAndNewlines))) +
+ """ + } + + private static func htmlNumber(_ value: Double) -> String { + let text = String(value) + return text.hasSuffix(".0") ? String(text.dropLast(2)) : text + } + + private static func nonEmptyAttribute(_ value: String?) -> String? { + guard let value, value.isEmpty == false else { + return nil + } + return value + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift new file mode 100644 index 0000000..689c851 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+ContainerHTML.swift @@ -0,0 +1,304 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func docmostContainerHTMLBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + if let callout = calloutHTMLBlock(in: lines, startingAt: index) { + return callout + } + + if let details = detailsContainerHTMLBlock(in: lines, startingAt: index) { + return details + } + + return mathBlockHTMLBlock(in: lines, startingAt: index) + } + + static func docmostContainerHTMLMarkdown(from block: NativeEditorBlock) -> String? { + switch block.kind { + case .callout(let callout): + callout.icon == nil ? nil : calloutHTMLMarkdown(from: callout) + case .details(let details): + detailsHTMLMarkdown(from: details) + default: + nil + } + } + + private static func calloutHTMLBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + guard + let attributes = htmlTagAttributes(from: lines[index], tagName: "div"), + attributes["data-type"]?.localizedCaseInsensitiveCompare("callout") == .orderedSame + else { + return nil + } + + let body = htmlContainerBody(in: lines, startingAt: index, tagName: "div") + guard let body else { return nil } + + let callout = NativeEditorCalloutBlock( + style: sanitizedContainerCalloutStyle(attributes["data-callout-type"] ?? "info"), + icon: nonEmptyContainerHTMLAttribute(attributes["data-callout-icon"]), + previewText: containerBodyText(from: body.lines) + ) + return ( + containerBlock( + kind: .callout(callout), + rawNode: NativeEditorRichBlockNodeFactory.calloutNode(from: callout) + ), + body.endIndex + ) + } + + private static func detailsContainerHTMLBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + let line = lines[index].trimmingCharacters(in: .whitespacesAndNewlines) + guard line.localizedCaseInsensitiveCompare("
") != .orderedSame, + let attributes = htmlTagAttributes(from: line, tagName: "details") else { + return nil + } + + var summary = "Details" + var contentLines: [String] = [] + var isInDetailsContent = false + var currentIndex = lines.index(after: index) + + while currentIndex < lines.endIndex { + let currentLine = lines[currentIndex] + let trimmedLine = currentLine.trimmingCharacters(in: .whitespacesAndNewlines) + + if containsHTMLClosingTag(in: trimmedLine, tagName: "details") { + let details = NativeEditorDetailsBlock( + summary: summary, + previewText: containerBodyText(from: contentLines), + isOpen: attributes.keys.contains("open") + ) + return ( + containerBlock( + kind: .details(details), + rawNode: NativeEditorRichBlockNodeFactory.detailsNode(from: details) + ), + lines.index(after: currentIndex) + ) + } + + if let parsedSummary = containerSummaryText(from: trimmedLine) { + summary = parsedSummary + } else if isDetailsContentOpeningLine(trimmedLine) { + isInDetailsContent = true + } else if isInDetailsContent, containsHTMLClosingTag(in: trimmedLine, tagName: "div") { + isInDetailsContent = false + } else if isInDetailsContent || trimmedLine.isEmpty == false { + contentLines.append(currentLine) + } + + currentIndex = lines.index(after: currentIndex) + } + + return nil + } + + private static func mathBlockHTMLBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + guard + let attributes = htmlTagAttributes(from: lines[index], tagName: "div"), + attributes["data-type"]?.localizedCaseInsensitiveCompare("mathBlock") == .orderedSame, + attributes.keys.contains("data-katex") + else { + return nil + } + + let body = htmlContainerBody(in: lines, startingAt: index, tagName: "div") + guard let body else { return nil } + + let math = NativeEditorMathBlock(text: containerBodyText(from: body.lines)) + return ( + containerBlock(kind: .mathBlock(math), rawNode: NativeEditorRichBlockNodeFactory.mathBlockNode(from: math)), + body.endIndex + ) + } + + private static func calloutHTMLMarkdown(from callout: NativeEditorCalloutBlock) -> String { + let openingTag = containerHTMLTag("div", attributes: [ + ("data-type", "callout"), + ("data-callout-type", sanitizedContainerCalloutStyle(callout.style)), + ("data-callout-icon", callout.icon) + ]) + + return """ + \(openingTag) + \(escapedInlineHTMLText(callout.previewText.trimmingCharacters(in: .whitespacesAndNewlines))) + + """ + } + + private static func detailsHTMLMarkdown(from details: NativeEditorDetailsBlock) -> String { + let openingTag = containerHTMLTag("details", attributes: [ + ("open", details.isOpen ? "" : nil) + ]) + let summary = escapedInlineHTMLText(details.summary.trimmingCharacters(in: .whitespacesAndNewlines)) + let body = escapedInlineHTMLText(details.previewText.trimmingCharacters(in: .whitespacesAndNewlines)) + + return """ + \(openingTag) + \(summary) +
+ \(body) +
+
+ """ + } + + private static func htmlContainerBody( + in lines: [String], + startingAt index: Array.Index, + tagName: String + ) -> (lines: [String], endIndex: Array.Index)? { + let line = lines[index] + if let inlineBody = inlineHTMLBody(in: line, tagName: tagName) { + return ([inlineBody], lines.index(after: index)) + } + + var bodyLines: [String] = [] + var currentIndex = lines.index(after: index) + while currentIndex < lines.endIndex { + let currentLine = lines[currentIndex] + if containsHTMLClosingTag(in: currentLine, tagName: tagName) { + if let bodyPrefix = htmlLinePrefixBeforeClosingTag(in: currentLine, tagName: tagName) { + bodyLines.append(bodyPrefix) + } + return (bodyLines, lines.index(after: currentIndex)) + } + + bodyLines.append(currentLine) + currentIndex = lines.index(after: currentIndex) + } + + return nil + } + + private static func inlineHTMLBody(in line: String, tagName: String) -> String? { + guard + containsHTMLClosingTag(in: line, tagName: tagName), + let openingEnd = line.firstIndex(of: ">"), + let closingRange = line.range(of: "", options: [.caseInsensitive, .backwards]) + else { + return nil + } + + let bodyStart = line.index(after: openingEnd) + guard bodyStart <= closingRange.lowerBound else { return "" } + return String(line[bodyStart.. String? { + guard let closingRange = line.range(of: "", options: [.caseInsensitive, .backwards]) else { + return nil + } + + let prefix = String(line[.. String? { + guard + line.localizedCaseInsensitiveContains(""), + let openingEnd = line.firstIndex(of: ">"), + let closingRange = line.range(of: "", options: [.caseInsensitive, .backwards]) + else { + return nil + } + + let contentStart = line.index(after: openingEnd) + guard contentStart <= closingRange.lowerBound else { return "" } + return unescapedInlineHTMLText(String(line[contentStart.. Bool { + guard let attributes = htmlTagAttributes(from: line, tagName: "div") else { + return false + } + + return attributes["data-type"]?.localizedCaseInsensitiveCompare("detailsContent") == .orderedSame + } + + private static func containerBodyText(from lines: [String]) -> String { + lines.compactMap(containerBodyLineText(from:)) + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func containerBodyLineText(from line: String) -> String? { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedLine.isEmpty == false else { return nil } + + if trimmedLine.localizedCaseInsensitiveCompare("

") == .orderedSame || + trimmedLine.localizedCaseInsensitiveCompare("

") == .orderedSame { + return nil + } + + if let paragraphText = paragraphHTMLText(from: trimmedLine) { + return paragraphText + } + + return unescapedInlineHTMLText(trimmedLine) + } + + private static func paragraphHTMLText(from line: String) -> String? { + guard + line.localizedCaseInsensitiveContains(""), + let openingEnd = line.firstIndex(of: ">"), + let closingRange = line.range(of: "

", options: [.caseInsensitive, .backwards]) + else { + return nil + } + + let contentStart = line.index(after: openingEnd) + guard contentStart <= closingRange.lowerBound else { return "" } + return unescapedInlineHTMLText(String(line[contentStart.. NativeEditorBlock { + NativeEditorBlock( + kind: kind, + text: AttributedString(NativeEditorDocument.previewText(for: kind)), + alignment: .left, + rawNode: rawNode + ) + } + + private static func containerHTMLTag(_ name: String, attributes: [(String, String?)]) -> String { + let attributeText = attributes.compactMap { key, value -> String? in + guard let value else { return nil } + return #"\#(key)="\#(escapedInlineHTMLAttribute(value))""# + }.joined(separator: " ") + + return attributeText.isEmpty ? "<\(name)>" : "<\(name) \(attributeText)>" + } + + private static func nonEmptyContainerHTMLAttribute(_ value: String?) -> String? { + guard let value, value.isEmpty == false else { + return nil + } + return value + } + + private static func sanitizedContainerCalloutStyle(_ value: String) -> String { + let sanitizedScalars = value.lowercased().unicodeScalars.filter { + CharacterSet.alphanumerics.contains($0) + } + let sanitized = String(String.UnicodeScalarView(sanitizedScalars)) + return sanitized.isEmpty ? "info" : sanitized + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift new file mode 100644 index 0000000..0c1cbf7 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+DocmostInlineHTML.swift @@ -0,0 +1,444 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func docmostInlineHTMLAttributes(from openingTag: String) -> [String: String] { + var attrs = [String: String]() + var index = openingTag.startIndex + + while index < openingTag.endIndex { + guard let nameRange = nextDocmostInlineHTMLAttributeNameRange(in: openingTag, startingAt: index) else { + break + } + + let name = String(openingTag[nameRange]).lowercased() + index = nameRange.upperBound + skipDocmostInlineHTMLWhitespace(in: openingTag, index: &index) + guard index < openingTag.endIndex, openingTag[index] == "=" else { + if name != "span" { + attrs[name] = "" + } + continue + } + + index = openingTag.index(after: index) + skipDocmostInlineHTMLWhitespace(in: openingTag, index: &index) + let value = docmostInlineHTMLAttributeValue(in: openingTag, startingAt: &index) + attrs[name] = unescapedInlineHTMLText(value) + } + + return attrs + } + + static func htmlTagAttributes(from line: String, tagName: String) -> [String: String]? { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard let range = openingHTMLTagRange(in: trimmedLine, tagName: tagName), + range.lowerBound == trimmedLine.startIndex else { + return nil + } + + return docmostInlineHTMLAttributes(from: String(trimmedLine[range])) + } + + static func firstHTMLTagAttributes(in line: String, tagName: String) -> [String: String]? { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard let range = openingHTMLTagRange(in: trimmedLine, tagName: tagName) else { + return nil + } + + return docmostInlineHTMLAttributes(from: String(trimmedLine[range])) + } + + static func containsHTMLClosingTag(in line: String, tagName: String) -> Bool { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + var searchStart = trimmedLine.startIndex + + while searchStart < trimmedLine.endIndex, + let openIndex = trimmedLine[searchStart...].firstIndex(of: "<") { + var nameStart = trimmedLine.index(after: openIndex) + guard nameStart < trimmedLine.endIndex else { break } + + guard trimmedLine[nameStart] == "/" else { + let nameEnd = htmlTagNameEnd(in: trimmedLine, startingAt: nameStart) + if let closeIndex = htmlOpeningTagCloseIndex(in: trimmedLine, startingAt: nameEnd) { + searchStart = trimmedLine.index(after: closeIndex) + } else { + searchStart = nameStart + } + continue + } + + nameStart = trimmedLine.index(after: nameStart) + let nameEnd = htmlTagNameEnd(in: trimmedLine, startingAt: nameStart) + let name = String(trimmedLine[nameStart.. Int { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + var delta = 0 + var searchStart = trimmedLine.startIndex + + while searchStart < trimmedLine.endIndex, + let openIndex = trimmedLine[searchStart...].firstIndex(of: "<") { + var nameStart = trimmedLine.index(after: openIndex) + guard nameStart < trimmedLine.endIndex else { break } + + let isClosingTag = trimmedLine[nameStart] == "/" + if isClosingTag { + nameStart = trimmedLine.index(after: nameStart) + } + + let nameEnd = htmlTagNameEnd(in: trimmedLine, startingAt: nameStart) + let name = String(trimmedLine[nameStart.. Range? { + let codeSpanRanges = markdownCodeSpanRanges(in: markdown, bodyStart: bodyStart) + var depth = 1 + var searchStart = bodyStart + + while searchStart < markdown.endIndex, + let closeRange = markdown[searchStart...].range(of: "", options: .caseInsensitive) { + if isInsideMarkdownCodeSpan(closeRange.lowerBound, ranges: codeSpanRanges) { + searchStart = closeRange.upperBound + continue + } + + if let nestedOpenRange = nextOpeningSpanRange( + in: markdown, + startingAt: searchStart, + before: closeRange.lowerBound, + codeSpanRanges: codeSpanRanges + ) { + depth += 1 + searchStart = nestedOpenRange.upperBound + continue + } + + depth -= 1 + if depth == 0 { + return closeRange + } + searchStart = closeRange.upperBound + } + + return nil + } + + private static func nextOpeningSpanRange( + in markdown: Substring, + startingAt searchStart: String.Index, + before upperBound: String.Index, + codeSpanRanges: [Range] + ) -> Range? { + var currentSearchStart = searchStart + + while currentSearchStart < upperBound, + let openRange = markdown[currentSearchStart.. [Range] { + var ranges = [Range]() + var activeBacktickRunStart: String.Index? + var activeBacktickRunLength: Int? + var currentIndex = bodyStart + + while currentIndex < markdown.endIndex { + guard markdown[currentIndex] == "`" else { + currentIndex = markdown.index(after: currentIndex) + continue + } + + let runStart = currentIndex + var runLength = 0 + while currentIndex < markdown.endIndex, markdown[currentIndex] == "`" { + runLength += 1 + currentIndex = markdown.index(after: currentIndex) + } + + if let activeLength = activeBacktickRunLength { + if activeLength == runLength { + if let activeBacktickRunStart { + ranges.append(activeBacktickRunStart..] + ) -> Bool { + var low = ranges.startIndex + var high = ranges.endIndex + + while low < high { + let mid = low + (high - low) / 2 + let range = ranges[mid] + + if index < range.lowerBound { + high = mid + } else if index >= range.upperBound { + low = mid + 1 + } else { + return true + } + } + + return false + } + + private static func openingHTMLTagRange(in text: String, tagName: String) -> Range? { + var searchStart = text.startIndex + + while searchStart < text.endIndex, + let openIndex = text[searchStart...].firstIndex(of: "<") { + let nameStart = text.index(after: openIndex) + guard nameStart < text.endIndex, text[nameStart] != "/" else { + searchStart = nameStart + continue + } + + let nameEnd = htmlTagNameEnd(in: text, startingAt: nameStart) + let name = String(text[nameStart.. Bool { + name.compare(tagName, options: .caseInsensitive) == .orderedSame + } + + private static func htmlTagNameEnd(in text: String, startingAt index: String.Index) -> String.Index { + var currentIndex = index + while currentIndex < text.endIndex, text[currentIndex].isDocmostHTMLTagNameChar { + currentIndex = text.index(after: currentIndex) + } + return currentIndex + } + + private static func htmlTagNameEnd(in text: Substring, startingAt index: String.Index) -> String.Index { + var currentIndex = index + while currentIndex < text.endIndex, text[currentIndex].isDocmostHTMLTagNameChar { + currentIndex = text.index(after: currentIndex) + } + return currentIndex + } + + private static func isHTMLTagBoundary(at index: String.Index, in text: String) -> Bool { + index == text.endIndex || text[index].isWhitespace || text[index] == ">" || text[index] == "/" + } + + private static func isHTMLTagBoundary(at index: String.Index, in text: Substring) -> Bool { + index == text.endIndex || text[index].isWhitespace || text[index] == ">" || text[index] == "/" + } + + private static func htmlOpeningTagCloseIndex(in text: String, startingAt index: String.Index) -> String.Index? { + var currentIndex = index + var quote: Character? + + while currentIndex < text.endIndex { + let character = text[currentIndex] + if let activeQuote = quote { + if character == activeQuote { + quote = nil + } + } else if character == "\"" || character == "'" { + quote = character + } else if character == ">" { + return currentIndex + } + + currentIndex = text.index(after: currentIndex) + } + + return nil + } + + private static func htmlOpeningTagCloseIndex(in text: Substring, startingAt index: String.Index) -> String.Index? { + var currentIndex = index + var quote: Character? + + while currentIndex < text.endIndex { + let character = text[currentIndex] + if let activeQuote = quote { + if character == activeQuote { + quote = nil + } + } else if character == "\"" || character == "'" { + quote = character + } else if character == ">" { + return currentIndex + } + + currentIndex = text.index(after: currentIndex) + } + + return nil + } + + private static func isSelfClosingHTMLTag(in text: String, closeIndex: String.Index) -> Bool { + guard closeIndex > text.startIndex else { return false } + + var currentIndex = text.index(before: closeIndex) + while currentIndex > text.startIndex, text[currentIndex].isWhitespace { + currentIndex = text.index(before: currentIndex) + } + + return text[currentIndex] == "/" + } + + static func escapedInlineHTMLAttribute(_ text: String) -> String { + escapedInlineHTMLText(text).replacing("\"", with: """) + } + + static func escapedInlineHTMLText(_ text: String) -> String { + text + .replacing("&", with: "&") + .replacing("<", with: "<") + .replacing(">", with: ">") + } + + static func unescapedInlineHTMLText(_ text: String) -> String { + text + .replacing(""", with: "\"") + .replacing("<", with: "<") + .replacing(">", with: ">") + .replacing("&", with: "&") + } + + private static func nextDocmostInlineHTMLAttributeNameRange( + in text: String, + startingAt index: String.Index + ) -> Range? { + var nameStart = index + while nameStart < text.endIndex, text[nameStart].isDocmostHTMLAttrNameChar == false { + nameStart = text.index(after: nameStart) + } + + guard nameStart < text.endIndex else { return nil } + + var nameEnd = nameStart + while nameEnd < text.endIndex, text[nameEnd].isDocmostHTMLAttrNameChar { + nameEnd = text.index(after: nameEnd) + } + + return nameStart.. String { + guard index < text.endIndex else { return "" } + + if text[index] == "\"" || text[index] == "'" { + let quote = text[index] + let valueStart = text.index(after: index) + guard let valueEnd = text[valueStart...].firstIndex(of: quote) else { + index = text.endIndex + return String(text[valueStart...]) + } + + index = text.index(after: valueEnd) + return String(text[valueStart.. + var mention: NativeEditorMention + } + + private struct DocmostCommentHTML { + var range: Range + var comment: NativeEditorInlineCommentMark + var bodyMarkdown: String + } + static func appendMarkdownText( _ markdown: String, to result: inout AttributedString, @@ -22,6 +34,72 @@ extension NativeEditorMarkdownParser { var remaining = markdown[...] var didAppendAtom = false + while let htmlComment = nextDocmostCommentHTML(in: remaining) { + appendMarkdownText( + String(remaining[.. String { - guard mention.entityType == "page", let slugID = mention.slugID, slugID.isEmpty == false else { - return fallbackText + guard mention.entityType == "page" else { + if mention.entityType == nil { + return fallbackText + } + return mentionHTMLMarkdown(from: mention, fallbackText: fallbackText) + } + + guard let slugID = mention.slugID, slugID.isEmpty == false else { + return mentionHTMLMarkdown(from: mention, fallbackText: fallbackText) } let label = escapedMarkdownLinkText(mention.label ?? fallbackText) @@ -50,6 +135,50 @@ extension NativeEditorMarkdownParser { return "[\(label)](/p/\(slugID)\(anchor))" } + static func commentMarkdown(from comments: [NativeEditorInlineCommentMark], body: String) -> String { + comments.normalizedNativeEditorInlineComments.reversed().reduce(body) { markdown, comment in + commentHTMLMarkdown(from: comment, body: markdown) + } + } + + private static func commentHTMLMarkdown(from comment: NativeEditorInlineCommentMark, body: String) -> String { + let className = comment.isResolved ? "comment-mark resolved" : "comment-mark" + let commentID = escapedInlineHTMLAttribute(comment.commentID) + let resolvedAttribute = comment.isResolved ? #" data-resolved="true""# : "" + return #""# + + body + + "" + } + + private static func mentionHTMLMarkdown(from mention: NativeEditorMention, fallbackText: String) -> String { + let attrs: [(String, String?)] = [ + ("data-type", "mention"), + ("data-id", mention.identifier), + ("data-label", mention.label), + ("data-entity-type", mention.entityType), + ("data-entity-id", mention.entityID), + ("data-slug-id", mention.slugID), + ("data-creator-id", mention.creatorID), + ("data-anchor-id", mention.anchorID) + ] + let attrText = attrs.compactMap { name, value in + value.nonEmpty.map { "\(name)=\"\(escapedInlineHTMLAttribute($0))\"" } + }.joined(separator: " ") + + let displayText = mentionHTMLDisplayText(from: mention, fallbackText: fallbackText) + return "\(escapedInlineHTMLText(displayText))" + } + + private static func mentionHTMLDisplayText(from mention: NativeEditorMention, fallbackText: String) -> String { + if mention.entityType == "user" { + let label = mention.label ?? fallbackText.removingMentionTrigger.nonEmpty ?? mention.entityID + ?? mention.identifier ?? "Mention" + return "@\(label)" + } + + return mention.label ?? fallbackText.nonEmpty ?? mention.entityID ?? mention.identifier ?? "Mention" + } + private static func appendMarkdownTextWithBareDocmostPageLinks( _ markdown: String, to result: inout AttributedString, @@ -109,6 +238,41 @@ extension NativeEditorMarkdownParser { result += segment } + private static func appendMention(_ mention: NativeEditorMention, to result: inout AttributedString) { + var segment = AttributedString(mention.displayText) + segment[NativeEditorMentionAttribute.self] = mention + result += segment + } + + private static func appendComment(_ htmlComment: DocmostCommentHTML, to result: inout AttributedString) { + var commentBody = AttributedString("") + appendMarkdownText( + htmlComment.bodyMarkdown, + to: &commentBody, + usesFoundationMarkdownParser: false + ) + applyComment(htmlComment.comment, to: &commentBody) + result += commentBody + } + + private static func applyComment(_ comment: NativeEditorInlineCommentMark, to text: inout AttributedString) { + let ranges = text.runs.map(\.range) + for range in ranges { + let comments = text[range].nativeEditorInlineComments.updatingNativeEditorInlineComment(comment) + text[range][NativeEditorCommentMarksAttribute.self] = comments.isEmpty ? nil : comments + text[range][NativeEditorCommentIDAttribute.self] = comments.first?.commentID + text[range][NativeEditorCommentResolvedAttribute.self] = comments.first?.isResolved + text[range].backgroundColor = commentBackgroundColor(for: comments) + } + } + + private static func commentBackgroundColor(for comments: [NativeEditorInlineCommentMark]) -> Color? { + guard comments.isEmpty == false else { return nil } + return comments.contains { $0.isResolved == false } + ? .yellow.opacity(0.28) + : .gray.opacity(0.16) + } + private static func plainMarkdownText(from markdown: String) -> String { let attributedText = (try? AttributedString(markdown: markdown)) ?? AttributedString(markdown) return String(attributedText.characters) @@ -128,9 +292,10 @@ extension NativeEditorMarkdownParser { let closeLabelIndex = markdown[markdown.index(after: openLabelIndex)...].firstIndex(of: "]"), markdown.index(after: closeLabelIndex) < markdown.endIndex, markdown[markdown.index(after: closeLabelIndex)] == "(", - let closeDestinationIndex = markdown[ - markdown.index(after: markdown.index(after: closeLabelIndex))... - ].firstIndex(of: ")") + let closeDestinationIndex = closingMarkdownLinkDestinationIndex( + in: markdown, + startingAt: markdown.index(after: markdown.index(after: closeLabelIndex)) + ) else { return nil } @@ -152,6 +317,100 @@ extension NativeEditorMarkdownParser { return nil } + private static func nextDocmostMentionHTML(in markdown: Substring) -> DocmostMentionHTML? { + var searchStart = markdown.startIndex + + while searchStart < markdown.endIndex, + let openRange = markdown[searchStart...].range(of: "") else { + return nil + } + + let openingTag = String(markdown[openRange.lowerBound...openTagEnd]) + let attrs = docmostInlineHTMLAttributes(from: openingTag) + guard attrs["data-type"] == "mention" else { + searchStart = markdown.index(after: openRange.lowerBound) + continue + } + + let contentStart = markdown.index(after: openTagEnd) + guard let closeRange = matchingCloseSpanRange(in: markdown, bodyStart: contentStart) else { + return nil + } + + let body = String(markdown[contentStart.. DocmostCommentHTML? { + var searchStart = markdown.startIndex + + while searchStart < markdown.endIndex, + let openRange = markdown[searchStart...].range(of: "") else { + return nil + } + + let openingTag = String(markdown[openRange.lowerBound...openTagEnd]) + let attrs = docmostInlineHTMLAttributes(from: openingTag) + guard let commentID = attrs["data-comment-id"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty + else { + searchStart = markdown.index(after: openRange.lowerBound) + continue + } + + let contentStart = markdown.index(after: openTagEnd) + guard let closeRange = matchingCloseSpanRange(in: markdown, bodyStart: contentStart) else { + return nil + } + + return DocmostCommentHTML( + range: openRange.lowerBound.. Bool { + guard let value else { return false } + + let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard normalizedValue.isEmpty == false else { return true } + + return normalizedValue != "false" && normalizedValue != "0" + } + + private static func mention(from attrs: [String: String], fallbackHTMLText: String) -> NativeEditorMention { + let entityType = attrs["data-entity-type"]?.nonEmpty + let fallbackLabel = unescapedInlineHTMLText(fallbackHTMLText) + .trimmingCharacters(in: .whitespacesAndNewlines) + .removingMentionTrigger + + return NativeEditorMention( + identifier: attrs["data-id"]?.nonEmpty, + label: attrs["data-label"]?.nonEmpty ?? fallbackLabel.nonEmpty, + entityType: entityType, + entityID: attrs["data-entity-id"]?.nonEmpty, + slugID: attrs["data-slug-id"]?.nonEmpty, + creatorID: attrs["data-creator-id"]?.nonEmpty, + anchorID: attrs["data-anchor-id"]?.nonEmpty + ) + } + private static func nextBareDocmostPageLink(in markdown: Substring) -> DocmostMarkdownLink? { var searchStart = markdown.startIndex @@ -283,6 +542,7 @@ extension NativeEditorMarkdownParser { .replacing("[", with: "\\[") .replacing("]", with: "\\]") } + } private extension Character { @@ -294,4 +554,26 @@ private extension Character { false } } + +} + +private extension Optional where Wrapped == String { + var nonEmpty: String? { + switch self { + case .some(let value): + value.nonEmpty + case .none: + nil + } + } +} + +private extension String { + var nonEmpty: String? { + isEmpty ? nil : self + } + + var removingMentionTrigger: String { + hasPrefix("@") ? String(dropFirst()) : self + } } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift new file mode 100644 index 0000000..de34203 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift @@ -0,0 +1,510 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func mediaHTMLBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + let line = lines[index] + + if let iframe = iframeHTMLBlock(in: lines, startingAt: index) { + return iframe + } + + if let image = imageHTMLBlock(from: line) { + return (image, lines.index(after: index)) + } + + if let video = mediaElementHTMLBlock(in: lines, startingAt: index, type: "video") { + return video + } + + if let audio = mediaElementHTMLBlock(in: lines, startingAt: index, type: "audio") { + return audio + } + + return typedMediaDivHTMLBlock(in: lines, startingAt: index) + } + + static func diagramHTMLBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + guard + let attributes = htmlTagAttributes(from: lines[index], tagName: "div"), + let type = diagramType(from: attributes["data-type"]) + else { + return nil + } + + var imageAttributes: [String: String] = [:] + var currentIndex = index + + while currentIndex < lines.endIndex { + let line = lines[currentIndex].trimmingCharacters(in: .whitespacesAndNewlines) + if let attrs = firstHTMLTagAttributes(in: line, tagName: "img") { + imageAttributes = attrs + } + if containsHTMLClosingTag(in: line, tagName: "div") { + let diagram = diagramBlock(from: attributes, imageAttributes: imageAttributes) + let kind = diagramKind(type: type, diagram: diagram) + return ( + NativeEditorBlock( + kind: kind, + text: AttributedString(NativeEditorDocument.previewText(for: kind)), + alignment: .left, + rawNode: NativeEditorRichBlockNodeFactory.diagramNode(from: diagram, type: type) + ), + lines.index(after: currentIndex) + ) + } + currentIndex = lines.index(after: currentIndex) + } + + return nil + } + + static func mediaHTMLMarkdown(from media: NativeEditorMediaBlock, type: String) -> String? { + guard mediaRequiresDocmostHTML(media, type: type) else { return nil } + + switch type { + case "image": + return imageHTMLMarkdown(from: media) + case "video": + return videoHTMLMarkdown(from: media) + case "audio": + return audioHTMLMarkdown(from: media) + default: + return nil + } + } + + static func pdfHTMLMarkdown(from pdf: NativeEditorPDFBlock) -> String? { + guard pdfRequiresDocmostHTML(pdf) else { return nil } + + let openingTag = htmlTag("div", attributes: [ + ("data-type", "pdf"), + ("src", pdf.source), + ("data-name", pdf.name), + ("data-attachment-id", pdf.attachmentID), + ("data-size", pdf.sizeInBytes.map(String.init)), + ("width", pdf.width), + ("height", pdf.height) + ]) + let frameTag = htmlTag("iframe", attributes: [ + ("src", pdf.source), + ("width", pdf.width), + ("height", pdf.height) + ]) + + return """ + \(openingTag) + \(frameTag) + + """ + } + + static func attachmentHTMLMarkdown(from attachment: NativeEditorAttachmentBlock) -> String? { + guard attachmentRequiresDocmostHTML(attachment) else { return nil } + + let openingTag = htmlTag("div", attributes: [ + ("data-type", "attachment"), + ("data-attachment-url", attachment.url), + ("data-attachment-name", attachment.name), + ("data-attachment-mime", attachment.mimeType), + ("data-attachment-size", attachment.sizeInBytes.map(String.init)), + ("data-attachment-id", attachment.attachmentID) + ]) + let linkTag = htmlTag("a", attributes: [ + ("href", attachment.url), + ("class", "attachment"), + ("target", "blank") + ]) + let title = escapedInlineHTMLText(attachment.name ?? attachment.url ?? "Attachment") + + return """ + \(openingTag) + \(linkTag)\(title) + + """ + } + + static func embedHTMLMarkdown(from embed: NativeEditorEmbedBlock) -> String? { + guard embed.provider?.lowercased() != "iframe" else { return nil } + guard embedRequiresDocmostHTML(embed) else { return nil } + + let openingTag = htmlTag("div", attributes: [ + ("data-type", "embed"), + ("data-src", embed.source), + ("data-provider", embed.provider), + ("data-align", embed.alignment), + ("data-width", embed.width), + ("data-height", embed.height) + ]) + let linkTag = htmlTag("a", attributes: [ + ("href", embed.source), + ("target", "blank") + ]) + let source = escapedInlineHTMLText(embed.source ?? "Embed") + + return """ + \(openingTag) + \(linkTag)\(source) + + """ + } + + static func diagramMarkdown(from diagram: NativeEditorDiagramBlock, type: String) -> String { + let openingTag = htmlTag("div", attributes: [ + ("data-type", type), + ("data-src", diagram.source), + ("data-title", diagram.title), + ("data-alt", diagram.alternativeText), + ("data-width", diagram.width), + ("data-height", diagram.height), + ("data-size", diagram.sizeInBytes.map(String.init)), + ("data-aspect-ratio", diagram.aspectRatio), + ("data-align", diagram.alignment), + ("data-attachment-id", diagram.attachmentID) + ]) + let imageTag = htmlTag("img", attributes: [ + ("src", diagram.source), + ("alt", diagram.alternativeText ?? diagram.title), + ("width", diagram.width) + ]) + + return """ + \(openingTag) + \(imageTag) + + """ + } + + private static func imageHTMLBlock(from line: String) -> NativeEditorBlock? { + guard let attributes = htmlTagAttributes(from: line, tagName: "img") else { + return nil + } + + let media = mediaBlock(from: attributes, sourceAttributes: [:], type: "image") + return htmlRichBlock( + kind: .image(media), + rawNode: NativeEditorRichBlockNodeFactory.mediaNode(from: media, type: "image") + ) + } + + private static func mediaElementHTMLBlock( + in lines: [String], + startingAt index: Array.Index, + type: String + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + guard let attributes = htmlTagAttributes(from: lines[index], tagName: type) else { + return nil + } + + var sourceAttributes: [String: String] = [:] + var currentIndex = index + + while currentIndex < lines.endIndex { + let line = lines[currentIndex].trimmingCharacters(in: .whitespacesAndNewlines) + if let attributes = firstHTMLTagAttributes(in: line, tagName: "source") { + sourceAttributes = attributes + } + + if line.localizedCaseInsensitiveContains("") { + let media = mediaBlock(from: attributes, sourceAttributes: sourceAttributes, type: type) + return ( + htmlRichBlock( + kind: type == "video" ? .video(media) : .audio(media), + rawNode: NativeEditorRichBlockNodeFactory.mediaNode(from: media, type: type) + ), + lines.index(after: currentIndex) + ) + } + + currentIndex = lines.index(after: currentIndex) + } + + return nil + } + + private static func typedMediaDivHTMLBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + guard + let attributes = htmlTagAttributes(from: lines[index], tagName: "div"), + let type = mediaDivType(from: attributes["data-type"]) + else { + return nil + } + + var childAttributes: [String: String] = [:] + var currentIndex = index + var containerDepth = 0 + + while currentIndex < lines.endIndex { + let line = lines[currentIndex].trimmingCharacters(in: .whitespacesAndNewlines) + if let attributes = firstHTMLTagAttributes(in: line, tagName: type == "pdf" ? "iframe" : "a") { + childAttributes = attributes + } + + containerDepth += htmlTagDepthDelta(in: line, tagName: "div") + if containerDepth <= 0 { + let block = typedMediaDivBlock(type: type, attributes: attributes, childAttributes: childAttributes) + return (block, lines.index(after: currentIndex)) + } + + currentIndex = lines.index(after: currentIndex) + } + + return nil + } + + private static func typedMediaDivBlock( + type: String, + attributes: [String: String], + childAttributes: [String: String] + ) -> NativeEditorBlock { + switch type { + case "pdf": + let pdf = pdfBlock(from: attributes, iframeAttributes: childAttributes) + return htmlRichBlock(kind: .pdf(pdf), rawNode: NativeEditorRichBlockNodeFactory.pdfNode(from: pdf)) + case "attachment": + let attachment = attachmentBlock(from: attributes, linkAttributes: childAttributes) + return htmlRichBlock( + kind: .attachment(attachment), + rawNode: NativeEditorRichBlockNodeFactory.attachmentNode(from: attachment) + ) + default: + let embed = embedBlock(from: attributes, linkAttributes: childAttributes) + return htmlRichBlock(kind: .embed(embed), rawNode: NativeEditorRichBlockNodeFactory.embedNode(from: embed)) + } + } + + private static func mediaBlock( + from attributes: [String: String], + sourceAttributes: [String: String], + type: String + ) -> NativeEditorMediaBlock { + let source = nonEmptyHTMLAttribute(attributes["src"]) ?? nonEmptyHTMLAttribute(sourceAttributes["src"]) + return NativeEditorMediaBlock( + source: source, + alternativeText: mediaAlternativeText(from: attributes, type: type), + title: nonEmptyHTMLAttribute(attributes["title"]), + attachmentID: mediaAttachmentID(from: attributes, source: source), + sizeInBytes: nonEmptyHTMLAttribute(attributes["data-size"]).flatMap(Int.init), + width: nonEmptyHTMLAttribute(attributes["width"]), + height: nonEmptyHTMLAttribute(attributes["height"]), + aspectRatio: nonEmptyHTMLAttribute(attributes["data-aspect-ratio"]), + alignment: nonEmptyHTMLAttribute(attributes["data-align"]) + ) + } + + private static func pdfBlock( + from attributes: [String: String], + iframeAttributes: [String: String] + ) -> NativeEditorPDFBlock { + let source = nonEmptyHTMLAttribute(attributes["src"]) ?? nonEmptyHTMLAttribute(iframeAttributes["src"]) + return NativeEditorPDFBlock( + source: source, + name: nonEmptyHTMLAttribute(attributes["data-name"]), + attachmentID: mediaAttachmentID(from: attributes, source: source), + sizeInBytes: nonEmptyHTMLAttribute(attributes["data-size"]).flatMap(Int.init), + width: nonEmptyHTMLAttribute(attributes["width"]) ?? nonEmptyHTMLAttribute(iframeAttributes["width"]), + height: nonEmptyHTMLAttribute(attributes["height"]) ?? nonEmptyHTMLAttribute(iframeAttributes["height"]) + ) + } + + private static func attachmentBlock( + from attributes: [String: String], + linkAttributes: [String: String] + ) -> NativeEditorAttachmentBlock { + let source = nonEmptyHTMLAttribute(attributes["data-attachment-url"]) ?? + nonEmptyHTMLAttribute(linkAttributes["href"]) + return NativeEditorAttachmentBlock( + url: source, + name: nonEmptyHTMLAttribute(attributes["data-attachment-name"]), + mimeType: nonEmptyHTMLAttribute(attributes["data-attachment-mime"]), + sizeInBytes: nonEmptyHTMLAttribute(attributes["data-attachment-size"]).flatMap(Int.init), + attachmentID: mediaAttachmentID(from: attributes, source: source) + ) + } + + private static func embedBlock( + from attributes: [String: String], + linkAttributes: [String: String] + ) -> NativeEditorEmbedBlock { + NativeEditorEmbedBlock( + source: nonEmptyHTMLAttribute(attributes["data-src"]) ?? nonEmptyHTMLAttribute(linkAttributes["href"]), + provider: nonEmptyHTMLAttribute(attributes["data-provider"]), + alignment: nonEmptyHTMLAttribute(attributes["data-align"]), + width: nonEmptyHTMLAttribute(attributes["data-width"]), + height: nonEmptyHTMLAttribute(attributes["data-height"]) + ) + } + + private static func htmlRichBlock(kind: NativeEditorBlockKind, rawNode: ProseMirrorNode) -> NativeEditorBlock { + NativeEditorBlock( + kind: kind, + text: AttributedString(NativeEditorDocument.previewText(for: kind)), + alignment: .left, + rawNode: rawNode + ) + } + + private static func imageHTMLMarkdown(from media: NativeEditorMediaBlock) -> String { + htmlTag("img", attributes: [ + ("src", media.source), + ("alt", media.alternativeText), + ("title", media.title), + ("width", media.width), + ("height", media.height), + ("data-align", media.alignment), + ("data-attachment-id", media.attachmentID), + ("data-size", media.sizeInBytes.map(String.init)), + ("data-aspect-ratio", media.aspectRatio) + ]) + } + + private static func videoHTMLMarkdown(from media: NativeEditorMediaBlock) -> String { + let openingTag = htmlTag("video", attributes: [ + ("controls", "true"), + ("src", media.source), + ("aria-label", media.alternativeText), + ("data-attachment-id", media.attachmentID), + ("width", media.width), + ("height", media.height), + ("data-size", media.sizeInBytes.map(String.init)), + ("data-align", media.alignment), + ("data-aspect-ratio", media.aspectRatio) + ]) + let sourceTag = htmlTag("source", attributes: [("src", media.source)]) + + return """ + \(openingTag) + \(sourceTag) + + """ + } + + private static func audioHTMLMarkdown(from media: NativeEditorMediaBlock) -> String { + let openingTag = htmlTag("audio", attributes: [ + ("controls", "true"), + ("preload", "metadata"), + ("src", media.source), + ("data-attachment-id", media.attachmentID), + ("data-size", media.sizeInBytes.map(String.init)) + ]) + let sourceTag = htmlTag("source", attributes: [("src", media.source)]) + + return """ + \(openingTag) + \(sourceTag) + + """ + } + + private static func mediaRequiresDocmostHTML(_ media: NativeEditorMediaBlock, type: String) -> Bool { + media.attachmentID != nil || + media.sizeInBytes != nil || + media.width != nil || + media.height != nil || + media.aspectRatio != nil || + media.alignment != nil || + (type == "video" && media.alternativeText != nil) + } + + private static func pdfRequiresDocmostHTML(_ pdf: NativeEditorPDFBlock) -> Bool { + pdf.attachmentID != nil || + pdf.sizeInBytes != nil || + pdf.width != nil || + pdf.height != nil + } + + private static func attachmentRequiresDocmostHTML(_ attachment: NativeEditorAttachmentBlock) -> Bool { + attachment.attachmentID != nil || + attachment.mimeType != nil || + attachment.sizeInBytes != nil + } + + private static func embedRequiresDocmostHTML(_ embed: NativeEditorEmbedBlock) -> Bool { + guard embed.provider?.lowercased() != "iframe" else { return false } + + return embed.provider != nil || + embed.alignment != nil || + embed.width != nil || + embed.height != nil + } + + private static func mediaDivType(from dataType: String?) -> String? { + guard let dataType else { return nil } + return ["pdf", "attachment", "embed"].first { + dataType.localizedCaseInsensitiveCompare($0) == .orderedSame + } + } + + private static func mediaAlternativeText(from attributes: [String: String], type: String) -> String? { + if type == "video" { + return nonEmptyHTMLAttribute(attributes["aria-label"]) ?? nonEmptyHTMLAttribute(attributes["alt"]) + } + + return nonEmptyHTMLAttribute(attributes["alt"]) + } + + private static func mediaAttachmentID(from attributes: [String: String], source: String?) -> String? { + nonEmptyHTMLAttribute(attributes["data-attachment-id"]) ?? source.flatMap(docmostAttachmentID) + } + + private static func htmlTag(_ name: String, attributes: [(String, String?)]) -> String { + let attributeText = attributes.compactMap { key, value -> String? in + guard let value = nonEmptyHTMLAttribute(value) else { return nil } + return #"\#(key)="\#(escapedInlineHTMLAttribute(value))""# + }.joined(separator: " ") + + return attributeText.isEmpty ? "<\(name)>" : "<\(name) \(attributeText)>" + } + + private static func diagramType(from dataType: String?) -> String? { + guard let dataType else { return nil } + if dataType.localizedCaseInsensitiveCompare("drawio") == .orderedSame { + return "drawio" + } + if dataType.localizedCaseInsensitiveCompare("excalidraw") == .orderedSame { + return "excalidraw" + } + return nil + } + + private static func diagramBlock( + from attributes: [String: String], + imageAttributes: [String: String] + ) -> NativeEditorDiagramBlock { + let source = nonEmptyHTMLAttribute(attributes["data-src"]) ?? nonEmptyHTMLAttribute(imageAttributes["src"]) + return NativeEditorDiagramBlock( + source: source, + title: nonEmptyHTMLAttribute(attributes["data-title"]), + alternativeText: nonEmptyHTMLAttribute(attributes["data-alt"]), + attachmentID: diagramAttachmentID(from: attributes, source: source), + sizeInBytes: nonEmptyHTMLAttribute(attributes["data-size"]).flatMap(Int.init), + width: nonEmptyHTMLAttribute(attributes["data-width"]) ?? nonEmptyHTMLAttribute(imageAttributes["width"]), + height: nonEmptyHTMLAttribute(attributes["data-height"]), + aspectRatio: nonEmptyHTMLAttribute(attributes["data-aspect-ratio"]), + alignment: nonEmptyHTMLAttribute(attributes["data-align"]) + ) + } + + private static func diagramKind(type: String, diagram: NativeEditorDiagramBlock) -> NativeEditorBlockKind { + type == "drawio" ? .drawio(diagram) : .excalidraw(diagram) + } + + private static func diagramAttachmentID(from attributes: [String: String], source: String?) -> String? { + nonEmptyHTMLAttribute(attributes["data-attachment-id"]) ?? source.flatMap(docmostAttachmentID) + } + + private static func nonEmptyHTMLAttribute(_ value: String?) -> String? { + guard let value, value.isEmpty == false else { + return nil + } + return value + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Highlight.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Highlight.swift new file mode 100644 index 0000000..d262775 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Highlight.swift @@ -0,0 +1,163 @@ +import Foundation +import SwiftUI + +extension NativeEditorMarkdownParser { + struct DocmostHighlightHTML { + var range: Range + var color: String? + var colorName: String? + var bodyMarkdown: String + } + + static func highlightMarkdown( + from run: AttributedString.Runs.Run, + body: String + ) -> String { + let color = run[NativeEditorHighlightColorAttribute.self]?.trimmedNonEmpty + let colorName = run[NativeEditorHighlightColorNameAttribute.self]?.trimmedNonEmpty + guard color != nil || colorName != nil else { return body } + + var attrs: [(String, String)] = [] + if let color { + attrs.append(("data-color", color)) + attrs.append(("style", "background-color: \(color); color: inherit")) + } + if let colorName { + attrs.append(("data-highlight-color-name", colorName.lowercased())) + } + + let attrText = attrs + .map { name, value in #"\#(name)="\#(escapedInlineHTMLAttribute(value))""# } + .joined(separator: " ") + return "\(body)" + } + + static func nextDocmostHighlightHTML(in markdown: Substring) -> DocmostHighlightHTML? { + var searchStart = markdown.startIndex + let codeSpanRanges = markdownCodeSpanRanges(in: markdown, bodyStart: markdown.startIndex) + + while searchStart < markdown.endIndex, + let openRange = markdown[searchStart...].range(of: "") else { + searchStart = openRange.upperBound + continue + } + + let contentStart = markdown.index(after: openTagEnd) + guard let closeRange = matchingCloseMarkRange( + in: markdown, + startingAt: contentStart, + codeSpanRanges: codeSpanRanges + ) else { + return nil + } + + let attrs = docmostInlineHTMLAttributes(from: String(markdown[openRange.lowerBound...openTagEnd])) + return DocmostHighlightHTML( + range: openRange.lowerBound..] + ) -> Range? { + var searchStart = contentStart + + while searchStart < markdown.endIndex, + let closeRange = markdown[searchStart...].range(of: "", options: .caseInsensitive) { + guard isInsideMarkdownCodeSpan(closeRange.lowerBound, ranges: codeSpanRanges) == false else { + searchStart = closeRange.upperBound + continue + } + + return closeRange + } + + return nil + } + + static func appendHighlight(_ htmlHighlight: DocmostHighlightHTML, to result: inout AttributedString) { + var highlightedBody = AttributedString("") + appendMarkdownText( + htmlHighlight.bodyMarkdown, + to: &highlightedBody, + usesFoundationMarkdownParser: false + ) + applyHighlight( + color: htmlHighlight.color, + colorName: htmlHighlight.colorName, + to: &highlightedBody + ) + result += highlightedBody + } + + private static func applyHighlight( + color: String?, + colorName: String?, + to text: inout AttributedString + ) { + guard color != nil || colorName != nil else { return } + + let ranges = text.runs.map(\.range) + for range in ranges { + if let color { + text[range][NativeEditorHighlightColorAttribute.self] = color + text[range].backgroundColor = Color(docmostlyHex: color) + } + if let colorName { + text[range][NativeEditorHighlightColorNameAttribute.self] = colorName + } + } + } + + private static func highlightColor(from attrs: [String: String]) -> String? { + if let color = attrs["data-color"]?.trimmedNonEmpty { + return color + } + + guard let style = attrs["style"] else { return nil } + return style + .split(separator: ";") + .compactMap { declaration -> String? in + let parts = declaration.split(separator: ":", maxSplits: 1).map(String.init) + guard parts.count == 2, + parts[0].trimmingCharacters(in: .whitespacesAndNewlines) + .localizedCaseInsensitiveCompare("background-color") == .orderedSame else { + return nil + } + + return parts[1].trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + } + .first + } + + private static func isHighlightHTMLTagBoundary(at index: String.Index, in text: Substring) -> Bool { + index == text.endIndex || text[index].isWhitespace || text[index] == ">" || text[index] == "/" + } +} + +private extension String { + var trimmedNonEmpty: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + var nonEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+IframeEmbeds.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+IframeEmbeds.swift new file mode 100644 index 0000000..bc56307 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+IframeEmbeds.swift @@ -0,0 +1,129 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func iframeHTMLBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + guard + let attributes = htmlTagAttributes(from: lines[index], tagName: "iframe"), + let source = nonEmptyIframeAttribute(attributes["src"]), + isWebURL(source) + else { + return nil + } + + var currentIndex = index + while currentIndex < lines.endIndex { + if containsHTMLClosingTag(in: lines[currentIndex], tagName: "iframe") { + return (iframeEmbedBlock(source: source), lines.index(after: currentIndex)) + } + currentIndex = lines.index(after: currentIndex) + } + + return (iframeEmbedBlock(source: source), lines.index(after: index)) + } + + static func iframeEmbedMarkdownBlock(from line: String) -> NativeEditorBlock? { + guard let link = iframeMarkdownLink(from: line), isWebEmbedSource(link.source) else { + return nil + } + + return iframeEmbedBlock(source: link.source) + } + + private static func iframeMarkdownLink(from line: String) -> (label: String, source: String)? { + guard line.hasPrefix("["), let closeLabelIndex = line.firstIndex(of: "]") else { + return nil + } + + let openDestinationIndex = line.index(after: closeLabelIndex) + guard + openDestinationIndex < line.endIndex, + line[openDestinationIndex] == "(", + let closeDestinationIndex = line.lastIndex(of: ")"), + closeDestinationIndex == line.index(before: line.endIndex) + else { + return nil + } + + let labelStartIndex = line.index(after: line.startIndex) + let label = unescapedMarkdownLinkLabel(String(line[labelStartIndex.. String { + var source = destination.trimmingCharacters(in: .whitespacesAndNewlines) + if source.hasPrefix("<"), source.hasSuffix(">") { + source.removeFirst() + source.removeLast() + } + return source.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func iframeEmbedBlock(source: String) -> NativeEditorBlock { + let embed = NativeEditorEmbedBlock( + source: source, + provider: "iframe", + alignment: nil, + width: nil, + height: nil + ) + + return NativeEditorBlock( + kind: .embed(embed), + text: AttributedString(source), + alignment: .left, + rawNode: NativeEditorRichBlockNodeFactory.embedNode(from: embed) + ) + } + + private static func unescapedMarkdownLinkLabel(_ text: String) -> String { + var result = "" + var isEscaped = false + + for character in text { + if isEscaped { + result.append(character) + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else { + result.append(character) + } + } + + return isEscaped ? result + "\\" : result + } + + private static func isWebEmbedSource(_ source: String) -> Bool { + guard isWebURL(source) else { return false } + + let path = URLComponents(string: source)?.percentEncodedPath.lowercased() ?? "" + return path == "/embed" || + path.contains("/embed/") || + path == "/live-embed" || + path.contains("/live-embed/") + } + + private static func isWebURL(_ source: String) -> Bool { + guard + let components = URLComponents(string: source), + let scheme = components.scheme?.lowercased(), + scheme == "https" || scheme == "http", + components.host?.isEmpty == false + else { + return false + } + + return true + } + + private static func nonEmptyIframeAttribute(_ value: String?) -> String? { + let trimmedValue = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedValue?.isEmpty == false ? trimmedValue : nil + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift index a618408..b8a605d 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift @@ -1,6 +1,16 @@ import Foundation extension NativeEditorMarkdownParser { + static func inlineMarkdownInputRuleText(from text: String) -> AttributedString? { + if let inlineMathText = inlineMathInputRuleText(from: text) { + return inlineMathText + } + + let attributedText = attributedInlineMarkdown(from: text) + guard String(attributedText.characters) != text else { return nil } + return attributedText + } + static func attributedInlineMarkdown(from markdown: String) -> AttributedString { var output = AttributedString("") var remaining = markdown[...] @@ -35,18 +45,52 @@ extension NativeEditorMarkdownParser { } } - if let href = run.link?.absoluteString { + if let href = run[NativeEditorLinkAttribute.self]?.href ?? run.link?.absoluteString { output = "[\(escapedMarkdownLinkLabel(output))](\(href))" } return output } + static func scriptUnderlineMarkdown( + from run: AttributedString.Runs.Run, + body: String + ) -> String { + var output = body + + if let baselineOffset = run.baselineOffset, baselineOffset != 0 { + let tagName = baselineOffset > 0 ? "sup" : "sub" + output = "<\(tagName)>\(output)" + } + + if run.underlineStyle != nil { + output = "\(output)" + } + + return output + } + private static func codeMarkdown(from text: String) -> String { - let delimiter = text.contains("`") ? "``" : "`" + let delimiter = String(repeating: "`", count: longestBacktickRunLength(in: text) + 1) return "\(delimiter)\(text)\(delimiter)" } + private static func longestBacktickRunLength(in text: String) -> Int { + var longestRunLength = 0 + var currentRunLength = 0 + + for character in text { + if character == "`" { + currentRunLength += 1 + longestRunLength = max(longestRunLength, currentRunLength) + } else { + currentRunLength = 0 + } + } + + return longestRunLength + } + private static func escapedMarkdownLinkLabel(_ text: String) -> String { text .replacing("\\", with: "\\\\") @@ -70,6 +114,12 @@ extension NativeEditorMarkdownParser { intent: .stronglyEmphasized, priority: 2 ), + delimitedInlineMarkdownMatch( + in: markdown, + delimiter: "__", + intent: .stronglyEmphasized, + priority: 2 + ), delimitedInlineMarkdownMatch( in: markdown, delimiter: "~~", @@ -81,6 +131,12 @@ extension NativeEditorMarkdownParser { delimiter: "*", intent: .emphasized, priority: 4 + ), + delimitedInlineMarkdownMatch( + in: markdown, + delimiter: "_", + intent: .emphasized, + priority: 4 ) ] .compactMap { $0 } @@ -94,26 +150,54 @@ extension NativeEditorMarkdownParser { } private static func codeInlineMarkdownMatch(in markdown: Substring) -> InlineMarkdownMatch? { - guard - let openIndex = markdown.firstIndex(of: "`"), - let closeIndex = markdown[markdown.index(after: openIndex)...].firstIndex(of: "`") - else { - return nil - } + guard let openingRun = nextBacktickRun(in: markdown, startingAt: markdown.startIndex), + let closingRun = nextMatchingBacktickRun(in: markdown, after: openingRun) else { return nil } - let contentStart = markdown.index(after: openIndex) - let content = String(markdown[contentStart.. + ) -> Range? { + var searchStart = openingRun.upperBound + + while let run = nextBacktickRun(in: markdown, startingAt: searchStart) { + if markdown.distance(from: run.lowerBound, to: run.upperBound) == + markdown.distance(from: openingRun.lowerBound, to: openingRun.upperBound) { + return run + } + + searchStart = run.upperBound + } + + return nil + } + + private static func nextBacktickRun( + in markdown: Substring, + startingAt searchStart: String.Index + ) -> Range? { + guard searchStart < markdown.endIndex, + let runStart = markdown[searchStart...].firstIndex(of: "`") else { return nil } + + var runEnd = runStart + while runEnd < markdown.endIndex, markdown[runEnd] == "`" { + runEnd = markdown.index(after: runEnd) + } + + return runStart.. InlineMarkdownMatch? { var searchStart = markdown.startIndex @@ -128,9 +212,10 @@ extension NativeEditorMarkdownParser { let closeLabelIndex = markdown[markdown.index(after: openLabelIndex)...].firstIndex(of: "]"), markdown.index(after: closeLabelIndex) < markdown.endIndex, markdown[markdown.index(after: closeLabelIndex)] == "(", - let closeDestinationIndex = markdown[ - markdown.index(after: markdown.index(after: closeLabelIndex))... - ].firstIndex(of: ")") + let closeDestinationIndex = closingMarkdownLinkDestinationIndex( + in: markdown, + startingAt: markdown.index(after: markdown.index(after: closeLabelIndex)) + ) else { return nil } @@ -138,7 +223,7 @@ extension NativeEditorMarkdownParser { let labelStartIndex = markdown.index(after: openLabelIndex) let destinationStartIndex = markdown.index(after: markdown.index(after: closeLabelIndex)) let label = String(markdown[labelStartIndex.. String { - let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) - - if trimmedDestination.hasPrefix("<"), - let closeIndex = trimmedDestination.firstIndex(of: ">") { - let sourceStartIndex = trimmedDestination.index(after: trimmedDestination.startIndex) - return String(trimmedDestination[sourceStartIndex.. String.Index? { + var scanner = MarkdownLinkDestinationScanner( + markdown: markdown, + destinationStartIndex: destinationStartIndex + ) + return scanner.closingIndex() } - private static func isPartOfStrongDelimiter( + private static func isPartOfRepeatedDelimiter( _ range: Range, + delimiter: String, in markdown: Substring ) -> Bool { + guard let delimiterCharacter = delimiter.first else { return false } + if range.lowerBound > markdown.startIndex, - markdown[markdown.index(before: range.lowerBound)] == "*" { + markdown[markdown.index(before: range.lowerBound)] == delimiterCharacter { return true } - return range.upperBound < markdown.endIndex && markdown[range.upperBound] == "*" + return range.upperBound < markdown.endIndex && markdown[range.upperBound] == delimiterCharacter + } + +} + +private struct MarkdownLinkDestinationScanner { + var markdown: Substring + var index: String.Index + var nestedParenthesisCount = 0 + var quoteDelimiter: Character? + var isInsideAngleDestination = false + var hasReadNonWhitespaceDestinationCharacter = false + + init(markdown: Substring, destinationStartIndex: String.Index) { + self.markdown = markdown + index = destinationStartIndex + } + + mutating func closingIndex() -> String.Index? { + while index < markdown.endIndex { + let character = markdown[index] + defer { index = markdown.index(after: index) } + + if consumesEscapedCharacter(character) || consumesQuotedCharacter(character) || + consumesAngleDestinationCharacter(character) || consumesOpeningAngleDestination(character) || + consumesTitleQuote(character) { + continue + } + + if let closingIndex = consumeParenthesis(character) { + return closingIndex + } + + markDestinationCharacter(character) + } + + return nil + } + + mutating private func consumesEscapedCharacter(_ character: Character) -> Bool { + guard isEscapedCharacter(at: index) else { return false } + markDestinationCharacter(character) + return true + } + + mutating private func consumesQuotedCharacter(_ character: Character) -> Bool { + guard let delimiter = quoteDelimiter else { return false } + if character == delimiter { + quoteDelimiter = nil + } + return true + } + + mutating private func consumesAngleDestinationCharacter(_ character: Character) -> Bool { + guard isInsideAngleDestination else { return false } + if character == ">" { + isInsideAngleDestination = false + } + return true + } + + mutating private func consumesOpeningAngleDestination(_ character: Character) -> Bool { + guard character == "<", hasReadNonWhitespaceDestinationCharacter == false else { return false } + isInsideAngleDestination = true + hasReadNonWhitespaceDestinationCharacter = true + return true + } + + mutating private func consumesTitleQuote(_ character: Character) -> Bool { + guard isMarkdownLinkTitleQuote(character) else { return false } + quoteDelimiter = character + return true + } + + mutating private func consumeParenthesis(_ character: Character) -> String.Index? { + switch character { + case "(": + nestedParenthesisCount += 1 + return nil + case ")": + guard nestedParenthesisCount > 0 else { return index } + nestedParenthesisCount -= 1 + return nil + default: + return nil + } + } + + mutating private func markDestinationCharacter(_ character: Character) { + if character.isWhitespace == false { + hasReadNonWhitespaceDestinationCharacter = true + } + } + + private func isMarkdownLinkTitleQuote(_ character: Character) -> Bool { + guard character == "\"" || character == "'", + index > markdown.startIndex else { + return false + } + + return markdown[markdown.index(before: index)].isWhitespace + } + + private func isEscapedCharacter(at index: String.Index) -> Bool { + var backslashCount = 0 + var currentIndex = index + + while currentIndex > markdown.startIndex { + let previousIndex = markdown.index(before: currentIndex) + guard markdown[previousIndex] == "\\" else { break } + + backslashCount += 1 + currentIndex = previousIndex + } + + return backslashCount.isMultiple(of: 2) == false } } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift new file mode 100644 index 0000000..a326c8f --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift @@ -0,0 +1,158 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func inlineText(from markdown: String) -> AttributedString { + var result = AttributedString("") + var remaining = markdown[...] + let codeSpanRanges = markdownCodeSpanRanges(in: remaining, bodyStart: remaining.startIndex) + + while let inlineDelimiter = nextInlineMathDelimiter(in: remaining, codeSpanRanges: codeSpanRanges) { + let openRange = inlineDelimiter.range + appendMarkdownText( + String(remaining[.. AttributedString? { + guard let shortcut = trailingInlineMathShortcut(in: text) else { return nil } + + var result = AttributedString(String(text[.. Bool { + let trimmedMarkdown = markdown.trimmingCharacters(in: .whitespacesAndNewlines) + return result.characters.isEmpty && trimmedMarkdown.hasPrefix("<") == false + } + + private static func appendInlineMath(_ text: String, to result: inout AttributedString) { + let math = NativeEditorMathInline(text: text) + var segment = AttributedString(text) + segment[NativeEditorMathInlineAttribute.self] = math + segment.inlinePresentationIntent = .code + result += segment + } + + private static func trailingInlineMathShortcut( + in text: String + ) -> (openingRange: Range, text: String)? { + guard text.hasSuffix("$$") else { return nil } + + let closingStart = text.index(text.endIndex, offsetBy: -2) + guard + let openingRange = text.range( + of: "$$", + options: .backwards, + range: text.startIndex.. text.startIndex { + let previousIndex = text.index(before: openingRange.lowerBound) + guard text[previousIndex].isWhitespace else { return nil } + } + + return (openingRange, mathText) + } + + private static func isValidInlineMathDelimiter( + _ delimiter: String, + openingRange: Range, + closingRange: Range, + in markdown: Substring + ) -> Bool { + let content = markdown[openingRange.upperBound.. markdown.startIndex { + let previousIndex = markdown.index(before: openingRange.lowerBound) + guard markdown[previousIndex] == " " else { return false } + } + + if delimiter == "$", closingRange.upperBound < markdown.endIndex { + return markdown[closingRange.upperBound].isNumber == false + } + + return true + } + + private static func nextInlineMathDelimiter( + in markdown: Substring, + codeSpanRanges: [Range] + ) -> (range: Range, value: String)? { + var searchStart = markdown.startIndex + + while searchStart < markdown.endIndex, + let dollarIndex = markdown[searchStart...].firstIndex(of: "$") { + let singleDollarRange = dollarIndex...Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + let line = lines[index] + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + guard + let rule = inputRule(from: trimmedLine), + isListItem(rule.kind), + let contentColumn = listContentColumn(from: line, trimmedLine: trimmedLine) + else { + return nil + } + + var text = inlineText(from: rule.text) + var currentIndex = lines.index(after: index) + + while currentIndex < lines.endIndex, + let continuationText = listContinuationText(from: lines[currentIndex], contentColumn: contentColumn) { + text += AttributedString("\n") + text += inlineText(from: continuationText) + currentIndex = lines.index(after: currentIndex) + } + + return ( + NativeEditorBlock( + kind: rule.kind, + text: text, + alignment: .left, + indentLevel: listIndentLevel(fromLeadingColumns: leadingColumns(in: line)) + ), + currentIndex + ) + } + + static func listItemMarkdown(prefix: String, continuationPrefix: String, text: String) -> String { + let lines = text.isEmpty ? [""] : text + .split(separator: "\n", omittingEmptySubsequences: false) + .map(String.init) + + return lines.enumerated().map { item in + "\(item.offset == 0 ? prefix : continuationPrefix)\(item.element)" + } + .joined(separator: "\n") + } + + private static func listContinuationText(from line: String, contentColumn: Int) -> String? { + guard leadingColumns(in: line) >= contentColumn else { return nil } + + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + if let rule = inputRule(from: trimmedLine), isListItem(rule.kind) { + return nil + } + + return textAfterDroppingColumns(contentColumn, from: line) + } + + private static func listContentColumn(from line: String, trimmedLine: String) -> Int? { + guard let markerWidth = listMarkerWidth(from: trimmedLine) else { return nil } + return leadingColumns(in: line) + markerWidth + } + + private static func listMarkerWidth(from trimmedLine: String) -> Int? { + let taskPrefixes = [ + "- [ ] ", "* [ ] ", "+ [ ] ", + "- [x] ", "- [X] ", "* [x] ", "* [X] ", "+ [x] ", "+ [X] " + ] + if let prefix = taskPrefixes.first(where: { trimmedLine.hasPrefix($0) }) { + return prefix.count + } + + if let bulletPrefix = ["- ", "* ", "+ "].first(where: { trimmedLine.hasPrefix($0) }) { + return bulletPrefix.count + } + + guard + let dotIndex = trimmedLine.firstIndex(of: "."), + trimmedLine.distance(from: trimmedLine.startIndex, to: dotIndex) <= 4 + else { + return nil + } + + let bodyStart = trimmedLine.index(after: dotIndex) + guard + Int(trimmedLine[.. String { + var columns = 0 + var index = line.startIndex + + while index < line.endIndex, columns < columnCount { + switch line[index] { + case " ": + columns += 1 + index = line.index(after: index) + case "\t": + columns += 2 + index = line.index(after: index) + default: + return String(line[index...]) + } + } + + return String(line[index...]) + } + + private static func leadingColumns(in line: String) -> Int { + var columns = 0 + + for character in line { + switch character { + case " ": + columns += 1 + case "\t": + columns += 2 + default: + return columns + } + } + + return columns + } + + private static func listIndentLevel(fromLeadingColumns columns: Int) -> Int { + min(columns / 2, 8) + } + + private static func isListItem(_ kind: NativeEditorBlockKind) -> Bool { + switch kind { + case .bulletListItem, .orderedListItem, .taskListItem: + true + default: + false + } + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+MarkdownLinkTitles.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+MarkdownLinkTitles.swift new file mode 100644 index 0000000..312bf02 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+MarkdownLinkTitles.swift @@ -0,0 +1,134 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func markdownLinkTitle(from destination: String) -> String? { + markdownLinkDestinationParts(from: destination).title + } + + static func markdownLinkSource(from destination: String) -> String { + markdownLinkDestinationParts(from: destination).source + } + + static func markdownLinkTitlePart(from title: String?) -> String { + guard let title, title.isEmpty == false else { return "" } + return " \"\(escapedMarkdownLinkTitle(title))\"" + } + + private static func escapedMarkdownLinkTitle(_ text: String) -> String { + text.replacing("\\", with: "\\\\") + .replacing("\"", with: "\\\"") + .replacing("\r", with: " ") + .replacing("\n", with: " ") + } + + private static func unescapedMarkdownLinkTitle(_ text: String) -> String { + var result = "" + var isEscaped = false + + for character in text { + if isEscaped { + result.append(character) + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else { + result.append(character) + } + } + + if isEscaped { + result.append("\\") + } + + return result + } + + private static func markdownLinkDestinationParts(from destination: String) -> (source: String, title: String?) { + let destination = destination.trimmingCharacters(in: .whitespacesAndNewlines) + guard let titleMatch = markdownLinkTitleMatch(in: destination) else { + return (normalizedMarkdownLinkSource(destination), nil) + } + + let rawTitle = String(destination[titleMatch.titleRange]) + let title = unescapedMarkdownLinkTitle(rawTitle) + let source = normalizedMarkdownLinkSource(String(destination[.. MarkdownLinkTitleMatch? { + guard destination.isEmpty == false else { return nil } + + let closeIndex = destination.index(before: destination.endIndex) + guard isEscapedCharacter(at: closeIndex, in: destination) == false else { return nil } + + switch destination[closeIndex] { + case "\"": + return delimiterTitleMatch(in: destination, openDelimiter: "\"", closeIndex: closeIndex) + case "'": + return delimiterTitleMatch(in: destination, openDelimiter: "'", closeIndex: closeIndex) + case ")": + return delimiterTitleMatch(in: destination, openDelimiter: "(", closeIndex: closeIndex) + default: + return nil + } + } + + private static func delimiterTitleMatch( + in destination: String, + openDelimiter: Character, + closeIndex: String.Index + ) -> MarkdownLinkTitleMatch? { + var match: MarkdownLinkTitleMatch? + var index = destination.startIndex + + while index < closeIndex { + if destination[index] == openDelimiter, + isEscapedCharacter(at: index, in: destination) == false, + index != destination.startIndex { + let previousIndex = destination.index(before: index) + if destination[previousIndex].isWhitespace, + isEscapedCharacter(at: previousIndex, in: destination) == false { + match = MarkdownLinkTitleMatch( + sourceEnd: previousIndex, + titleRange: destination.index(after: index).. String { + var source = source.trimmingCharacters(in: .whitespacesAndNewlines) + + if source.hasPrefix("<"), source.hasSuffix(">") { + source.removeFirst() + source.removeLast() + } + + return source.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func isEscapedCharacter(at index: String.Index, in text: String) -> Bool { + var backslashCount = 0 + var currentIndex = index + + while currentIndex > text.startIndex { + let previousIndex = text.index(before: currentIndex) + guard text[previousIndex] == "\\" else { break } + + backslashCount += 1 + currentIndex = previousIndex + } + + return backslashCount.isMultiple(of: 2) == false + } +} + +private struct MarkdownLinkTitleMatch { + let sourceEnd: String.Index + let titleRange: Range +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+MathBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+MathBlocks.swift new file mode 100644 index 0000000..29b0a99 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+MathBlocks.swift @@ -0,0 +1,28 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func singleLineMathFenceBlock(from line: String) -> NativeEditorBlock? { + guard + line.hasPrefix("$$"), + line.hasPrefix("$$$") == false, + line.hasSuffix("$$") + else { + return nil + } + + let contentStart = line.index(line.startIndex, offsetBy: 2) + let contentEnd = line.index(line.endIndex, offsetBy: -2) + guard contentStart <= contentEnd else { return nil } + + let mathText = String(line[contentStart.. NativeEditorBlock? { + let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) + let lowercasedLine = trimmedLine.lowercased() + guard lowercasedLine.hasPrefix(""), + tagEnd > trimmedLine.startIndex + else { + return nil + } + + let openingTag = String(trimmedLine[trimmedLine.startIndex.. Bool { + if attributes["data-type"]?.localizedCaseInsensitiveCompare("pageBreak") == .orderedSame { + return true + } + + let style = attributes["style"]?.lowercased() ?? "" + return style.contains("page-break-after") && style.contains("always") + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift index 6b79166..20b8493 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+RichBlocks.swift @@ -13,11 +13,33 @@ extension NativeEditorMarkdownParser { return calloutBlock } + if let mediaBlock = mediaHTMLBlock(in: lines, startingAt: index) { + return mediaBlock + } + + if let diagramBlock = diagramHTMLBlock(in: lines, startingAt: index) { + return diagramBlock + } + + if let containerBlock = docmostContainerHTMLBlock(in: lines, startingAt: index) { + return containerBlock + } + + if let structuralBlock = docmostStructuralHTMLBlock(in: lines, startingAt: index) { + return structuralBlock + } + + if let columnsBlock = columnsHTMLBlock(in: lines, startingAt: index) { + return columnsBlock + } + return detailsHTMLBlock(in: lines, startingAt: index) } static func singleLineRichBlock(from line: String) -> NativeEditorBlock? { - imageMarkdownBlock(from: line) ?? linkedFileMarkdownBlock(from: line) + singleLineMathFenceBlock(from: line) ?? pageBreakHTMLBlock(from: line) ?? imageMarkdownBlock(from: line) ?? + iframeEmbedMarkdownBlock(from: line) ?? + linkedFileMarkdownBlock(from: line) } static func richMarkdownLine(from block: NativeEditorBlock) -> String? { @@ -25,6 +47,10 @@ extension NativeEditorMarkdownParser { return mediaMarkdown } + if let containerMarkdown = docmostContainerHTMLMarkdown(from: block) { + return containerMarkdown + } + if let structuralMarkdown = structuralMarkdownLine(from: block) { return structuralMarkdown } @@ -35,36 +61,39 @@ extension NativeEditorMarkdownParser { private static func mediaMarkdownLine(from block: NativeEditorBlock) -> String? { switch block.kind { case .image(let media): - imageMarkdown(from: media) + mediaHTMLMarkdown(from: media, type: "image") ?? imageMarkdown(from: media) case .video(let media): - mediaLinkMarkdown(from: media, fallbackTitle: "Video") + mediaHTMLMarkdown(from: media, type: "video") ?? mediaLinkMarkdown(from: media, fallbackTitle: "Video") case .audio(let media): - mediaLinkMarkdown(from: media, fallbackTitle: "Audio") + mediaHTMLMarkdown(from: media, type: "audio") ?? mediaLinkMarkdown(from: media, fallbackTitle: "Audio") case .pdf(let pdf): - linkMarkdown(title: pdf.name ?? pdf.source ?? "PDF", url: pdf.source) + pdfHTMLMarkdown(from: pdf) ?? + linkMarkdown(title: pdf.name ?? markdownLinkDisplayName(from: pdf.source) ?? "PDF", url: pdf.source) case .attachment(let attachment): - linkMarkdown(title: attachment.name ?? attachment.url ?? "Attachment", url: attachment.url) + attachmentHTMLMarkdown(from: attachment) ?? + linkMarkdown( + title: attachment.name ?? markdownLinkDisplayName(from: attachment.url) ?? "Attachment", + url: attachment.url + ) default: nil } } private static func structuralMarkdownLine(from block: NativeEditorBlock) -> String? { - switch block.kind { + if let structuralHTML = docmostStructuralHTMLMarkdown(from: block) { + return structuralHTML + } + + return switch block.kind { case .callout(let callout): calloutMarkdown(from: callout) case .details(let details): detailsMarkdown(from: details) case .pageBreak: - #"
"# + #"
"# case .columns(let columns): columnsMarkdown(from: columns) - case .subpages: - "" - case .transclusionSource(let source): - transclusionSourceMarkdown(from: source) - case .transclusionReference(let reference): - transclusionReferenceMarkdown(from: reference) default: nil } @@ -73,11 +102,11 @@ extension NativeEditorMarkdownParser { private static func embeddedMarkdownLine(from block: NativeEditorBlock) -> String? { switch block.kind { case .embed(let embed): - linkMarkdown(title: embed.provider ?? embed.source ?? "Embed", url: embed.source) + embedHTMLMarkdown(from: embed) ?? embedMarkdown(from: embed) case .drawio(let diagram): - diagramMarkdown(from: diagram, fallbackTitle: "Draw.io diagram") + diagramMarkdown(from: diagram, type: "drawio") case .excalidraw(let diagram): - diagramMarkdown(from: diagram, fallbackTitle: "Excalidraw diagram") + diagramMarkdown(from: diagram, type: "excalidraw") case .mathBlock(let math): mathMarkdown(from: math) case .unsupported: @@ -221,13 +250,15 @@ extension NativeEditorMarkdownParser { let destinationStartIndex = line.index(after: openDestinationIndex) let destination = String(line[destinationStartIndex.. NativeEditorBlockKind? { guard let fileExtension = markdownLinkFileExtension(from: source) else { return nil } let title = title.isEmpty ? nil : title + let attachmentID = docmostAttachmentID(from: source) if videoFileExtensions.contains(fileExtension) { return .video(NativeEditorMediaBlock( source: source, alternativeText: nil, title: title, - attachmentID: nil, + attachmentID: attachmentID, sizeInBytes: nil, width: nil, height: nil, @@ -293,7 +325,7 @@ extension NativeEditorMarkdownParser { source: source, alternativeText: nil, title: title, - attachmentID: nil, + attachmentID: attachmentID, sizeInBytes: nil, width: nil, height: nil, @@ -306,7 +338,7 @@ extension NativeEditorMarkdownParser { return .pdf(NativeEditorPDFBlock( source: source, name: title, - attachmentID: nil, + attachmentID: attachmentID, sizeInBytes: nil, width: nil, height: nil @@ -318,7 +350,7 @@ extension NativeEditorMarkdownParser { name: title, mimeType: nil, sizeInBytes: nil, - attachmentID: nil + attachmentID: attachmentID )) } @@ -351,11 +383,13 @@ extension NativeEditorMarkdownParser { return media.alternativeText ?? "Image" } - return "![\(escapedMarkdownLinkText(media.alternativeText ?? ""))](\(source))" + let titlePart = markdownLinkTitlePart(from: media.title) + return "![\(escapedMarkdownLinkText(media.alternativeText ?? ""))](\(source)\(titlePart))" } private static func mediaLinkMarkdown(from media: NativeEditorMediaBlock, fallbackTitle: String) -> String { - linkMarkdown(title: media.alternativeText ?? media.title ?? media.source ?? fallbackTitle, url: media.source) + let title = media.alternativeText ?? media.title ?? markdownLinkDisplayName(from: media.source) ?? fallbackTitle + return linkMarkdown(title: title, url: media.source) } private static func calloutMarkdown(from callout: NativeEditorCalloutBlock) -> String { @@ -377,33 +411,12 @@ extension NativeEditorMarkdownParser { """ } - private static func columnsMarkdown(from columns: NativeEditorColumnsBlock) -> String { - let columnTexts = columns.columnTexts.isEmpty ? [columns.previewText] : columns.columnTexts - return columnTexts.enumerated().map { index, text in - "### Column \(index + 1)\n\(text.trimmedMarkdownBlockText)" - }.joined(separator: "\n\n") - } - - private static func transclusionSourceMarkdown(from source: NativeEditorTransclusionSourceBlock) -> String { - if let identifier = source.identifier, identifier.isEmpty == false { - return "\n\(source.previewText.trimmedMarkdownBlockText)" + private static func embedMarkdown(from embed: NativeEditorEmbedBlock) -> String { + if embed.provider == "iframe", let source = embed.source, source.isEmpty == false { + return linkMarkdown(title: source, url: source) } - return source.previewText.trimmedMarkdownBlockText - } - - private static func transclusionReferenceMarkdown( - from reference: NativeEditorTransclusionReferenceBlock - ) -> String { - let identifier = reference.transclusionID ?? reference.sourcePageID ?? "unknown" - return "" - } - - private static func diagramMarkdown(from diagram: NativeEditorDiagramBlock, fallbackTitle: String) -> String { - linkMarkdown( - title: diagram.title ?? diagram.alternativeText ?? diagram.source ?? fallbackTitle, - url: diagram.source - ) + return linkMarkdown(title: embed.provider ?? embed.source ?? "Embed", url: embed.source) } private static func mathMarkdown(from math: NativeEditorMathBlock) -> String { @@ -419,6 +432,18 @@ extension NativeEditorMarkdownParser { return "[\(escapedMarkdownLinkText(title))](\(url))" } + private static func markdownLinkDisplayName(from source: String?) -> String? { + guard let source, source.isEmpty == false else { return nil } + let path = markdownLinkPath(from: source) + .trimmingCharacters(in: CharacterSet(charactersIn: "/\\")) + guard path.isEmpty == false else { return nil } + + let separatorIndex = path.lastIndex { $0 == "/" || $0 == "\\" } + let nameStart = separatorIndex.map { path.index(after: $0) } ?? path.startIndex + let name = String(path[nameStart...]) + return name.isEmpty ? nil : name + } + private static func sanitizedCalloutStyle(_ value: String) -> String { let sanitizedScalars = value.lowercased().unicodeScalars.filter { CharacterSet.alphanumerics.contains($0) @@ -457,43 +482,56 @@ extension NativeEditorMarkdownParser { return unescapedHTMLText(String(line[contentStart.. String { - var source = destination.trimmingCharacters(in: .whitespacesAndNewlines) - - if source.hasPrefix("<"), source.hasSuffix(">") { - source.removeFirst() - source.removeLast() + private static func markdownLinkFileExtension(from source: String) -> String? { + let path = markdownLinkPath(from: source) + guard + let fileExtension = path.split(separator: ".").last?.lowercased(), + fileExtension != path.lowercased() + else { + return nil } - if let titleRange = source.range(of: " \"") { - source = String(source[.. String? { + let pathComponents = markdownLinkPath(from: source) + .split(separator: "/", omittingEmptySubsequences: true) + .map(String.init) + guard + let apiIndex = pathComponents.firstIndex(of: "api"), + pathComponents.indices.contains(pathComponents.index(after: apiIndex)) + else { + return nil } - return source.trimmingCharacters(in: .whitespacesAndNewlines) + let filesIndex = pathComponents.index(after: apiIndex) + guard pathComponents[filesIndex] == "files" else { return nil } + + let attachmentIndex = pathComponents.index(after: filesIndex) + guard pathComponents.indices.contains(attachmentIndex) else { return nil } + + let filenameIndex = pathComponents.index(after: attachmentIndex) + guard pathComponents.indices.contains(filenameIndex) else { return nil } + + let attachmentID = pathComponents[attachmentIndex] + guard attachmentID.isEmpty == false else { return nil } + return attachmentID.removingPercentEncoding ?? attachmentID } - private static func markdownLinkFileExtension(from source: String) -> String? { + private static func markdownLinkPath(from source: String) -> String { let pathSource: String if let components = URLComponents(string: source), components.scheme != nil { - guard let componentPath = components.path.nonEmpty else { return nil } - pathSource = componentPath + pathSource = components.path.nonEmpty ?? "" } else { pathSource = source } - let path = pathSource.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false) + return pathSource.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false) .first? .split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false) .first .map(String.init) ?? pathSource - guard - let fileExtension = path.split(separator: ".").last?.lowercased(), - fileExtension != path.lowercased() - else { - return nil - } - - return fileExtension } private static func escapedMarkdownLinkText(_ text: String) -> String { diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+ScriptUnderline.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+ScriptUnderline.swift new file mode 100644 index 0000000..574ba9d --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+ScriptUnderline.swift @@ -0,0 +1,166 @@ +import Foundation + +extension NativeEditorMarkdownParser { + struct DocmostScriptUnderlineHTML { + var range: Range + var mark: NativeEditorTextMark + var bodyMarkdown: String + } + + static func nextDocmostScriptUnderlineHTML(in markdown: Substring) -> DocmostScriptUnderlineHTML? { + ["u", "sup", "sub"] + .compactMap { nextDocmostScriptUnderlineHTML(in: markdown, tagName: $0) } + .min { lhs, rhs in lhs.range.lowerBound < rhs.range.lowerBound } + } + + static func appendScriptUnderline(_ htmlMark: DocmostScriptUnderlineHTML, to result: inout AttributedString) { + var body = AttributedString("") + appendMarkdownText( + htmlMark.bodyMarkdown, + to: &body, + usesFoundationMarkdownParser: false + ) + NativeEditorDocument.apply([htmlMark.mark], to: &body) + result += body + } + + private static func nextDocmostScriptUnderlineHTML( + in markdown: Substring, + tagName: String + ) -> DocmostScriptUnderlineHTML? { + var searchStart = markdown.startIndex + let codeSpanRanges = markdownCodeSpanRanges(in: markdown, bodyStart: markdown.startIndex) + + while searchStart < markdown.endIndex, + let openingRange = nextOpeningScriptUnderlineTag( + in: markdown, + tagName: tagName, + startingAt: searchStart + ) { + guard isInsideMarkdownCodeSpan(openingRange.lowerBound, ranges: codeSpanRanges) == false else { + searchStart = openingRange.upperBound + continue + } + + guard let closeRange = matchingClosingScriptUnderlineTag( + in: markdown, + tagName: tagName, + startingAt: openingRange.upperBound, + codeSpanRanges: codeSpanRanges + ) else { + return nil + } + + return DocmostScriptUnderlineHTML( + range: openingRange.lowerBound.. Range? { + var currentSearchStart = searchStart + + while currentSearchStart < markdown.endIndex, + let openIndex = markdown[currentSearchStart...].firstIndex(of: "<") { + let nameStart = markdown.index(after: openIndex) + guard nameStart < markdown.endIndex, markdown[nameStart] != "/" else { + currentSearchStart = nameStart + continue + } + + let nameEnd = scriptUnderlineTagNameEnd(in: markdown, startingAt: nameStart) + let name = String(markdown[nameStart..] + ) -> Range? { + let closingTag = "" + var searchStart = bodyStart + + while searchStart < markdown.endIndex, + let closeRange = markdown[searchStart...].range(of: closingTag, options: .caseInsensitive) { + guard isInsideMarkdownCodeSpan(closeRange.lowerBound, ranges: codeSpanRanges) == false else { + searchStart = closeRange.upperBound + continue + } + + return closeRange + } + + return nil + } + + private static func scriptUnderlineMark(for tagName: String) -> NativeEditorTextMark { + switch tagName { + case "sup": + .superscript + case "sub": + .subscript + default: + .underline + } + } + + private static func scriptUnderlineTagNameEnd( + in markdown: Substring, + startingAt index: String.Index + ) -> String.Index { + var currentIndex = index + while currentIndex < markdown.endIndex, markdown[currentIndex].isLetter { + currentIndex = markdown.index(after: currentIndex) + } + return currentIndex + } + + private static func isScriptUnderlineTagBoundary(at index: String.Index, in markdown: Substring) -> Bool { + index == markdown.endIndex || markdown[index].isWhitespace || markdown[index] == ">" || markdown[index] == "/" + } + + private static func scriptUnderlineOpeningTagEnd( + in markdown: Substring, + startingAt index: String.Index + ) -> String.Index? { + var currentIndex = index + var quote: Character? + + while currentIndex < markdown.endIndex { + let character = markdown[currentIndex] + if let activeQuote = quote { + if character == activeQuote { + quote = nil + } + } else if character == "\"" || character == "'" { + quote = character + } else if character == ">" { + return currentIndex + } + + currentIndex = markdown.index(after: currentIndex) + } + + return nil + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Status.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Status.swift new file mode 100644 index 0000000..438fa66 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Status.swift @@ -0,0 +1,58 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func statusMarkdown(from status: NativeEditorStatusBadge) -> String { + let color = escapedInlineHTMLAttribute(status.color) + let text = escapedInlineHTMLText(status.text) + return #"\#(text)"# + } + + static func nextDocmostStatusHTML( + in markdown: Substring + ) -> (range: Range, status: NativeEditorStatusBadge)? { + var searchStart = markdown.startIndex + + while searchStart < markdown.endIndex, + let openRange = markdown[searchStart...].range(of: "") else { + return nil + } + + let openingTag = String(markdown[openRange.lowerBound...openTagEnd]) + let attrs = docmostInlineHTMLAttributes(from: openingTag) + guard attrs["data-type"]?.localizedCaseInsensitiveCompare("status") == .orderedSame else { + searchStart = markdown.index(after: openRange.lowerBound) + continue + } + + let contentStart = markdown.index(after: openTagEnd) + guard let closeRange = matchingCloseSpanRange(in: markdown, bodyStart: contentStart) else { + searchStart = contentStart + continue + } + + return ( + openRange.lowerBound...Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + guard let attributes = docmostStructuralHTMLAttributes(from: lines[index]) else { + return nil + } + + switch attributes["data-type"] { + case "subpages": + return ( + docmostStructuralBlock(kind: .subpages, rawNode: ProseMirrorNode(type: "subpages")), + lines.index(after: index) + ) + case "transclusionSource": + return transclusionSourceHTMLBlock(in: lines, startingAt: index, attributes: attributes) + case "transclusionReference": + let reference = NativeEditorTransclusionReferenceBlock( + sourcePageID: nonEmptyStructuralHTMLAttribute(attributes["data-source-page-id"]), + transclusionID: nonEmptyStructuralHTMLAttribute(attributes["data-transclusion-id"]) + ) + return ( + docmostStructuralBlock( + kind: .transclusionReference(reference), + rawNode: NativeEditorRichBlockNodeFactory.transclusionReferenceNode(from: reference) + ), + lines.index(after: index) + ) + case "base-embed": + let base = NativeEditorBaseBlock( + pageID: nonEmptyStructuralHTMLAttribute(attributes["data-page-id"]), + pendingKey: nil, + previewText: "Base" + ) + return ( + docmostStructuralBlock( + kind: .base(base), + rawNode: NativeEditorRichBlockNodeFactory.baseNode(from: base) + ), + lines.index(after: index) + ) + default: + return nil + } + } + + static func docmostStructuralHTMLMarkdown(from block: NativeEditorBlock) -> String? { + switch block.kind { + case .subpages: + #"
"# + case .transclusionSource(let source): + transclusionSourceHTMLMarkdown(from: source) + case .transclusionReference(let reference): + transclusionReferenceHTMLMarkdown(from: reference) + case .base(let base): + baseHTMLMarkdown(from: base) + default: + nil + } + } + + private static func transclusionSourceHTMLBlock( + in lines: [String], + startingAt index: Array.Index, + attributes: [String: String] + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + var bodyLines: [String] = [] + var currentIndex = lines.index(after: index) + + while currentIndex < lines.endIndex { + let line = lines[currentIndex] + if containsHTMLClosingTag(in: line, tagName: "div") { + let source = NativeEditorTransclusionSourceBlock( + identifier: nonEmptyStructuralHTMLAttribute(attributes["data-id"]), + previewText: structuralHTMLText(from: bodyLines) + ) + return ( + docmostStructuralBlock( + kind: .transclusionSource(source), + rawNode: NativeEditorRichBlockNodeFactory.transclusionSourceNode(from: source) + ), + lines.index(after: currentIndex) + ) + } + + bodyLines.append(line) + currentIndex = lines.index(after: currentIndex) + } + + return nil + } + + private static func transclusionSourceHTMLMarkdown(from source: NativeEditorTransclusionSourceBlock) -> String { + let openingTag = structuralHTMLTag("div", attributes: [ + ("data-type", "transclusionSource"), + ("data-id", source.identifier) + ]) + let previewText = escapedInlineHTMLText(source.previewText.trimmingCharacters(in: .whitespacesAndNewlines)) + + return """ + \(openingTag) + \(previewText) + + """ + } + + private static func transclusionReferenceHTMLMarkdown( + from reference: NativeEditorTransclusionReferenceBlock + ) -> String { + let openingTag = structuralHTMLTag("div", attributes: [ + ("data-type", "transclusionReference"), + ("data-source-page-id", reference.sourcePageID), + ("data-transclusion-id", reference.transclusionID) + ]) + return "\(openingTag)" + } + + private static func baseHTMLMarkdown(from base: NativeEditorBaseBlock) -> String { + let openingTag = structuralHTMLTag("div", attributes: [ + ("data-type", "base-embed"), + ("data-page-id", base.pageID) + ]) + return "\(openingTag)" + } + + private static func docmostStructuralBlock( + kind: NativeEditorBlockKind, + rawNode: ProseMirrorNode + ) -> NativeEditorBlock { + NativeEditorBlock( + kind: kind, + text: AttributedString(NativeEditorDocument.previewText(for: kind)), + alignment: .left, + rawNode: rawNode + ) + } + + private static func docmostStructuralHTMLAttributes(from line: String) -> [String: String]? { + guard let attributes = htmlTagAttributes(from: line, tagName: "div") else { + return nil + } + + guard let dataType = attributes["data-type"], + docmostStructuralHTMLTypes.contains(dataType) else { + return nil + } + + return attributes + } + + private static func structuralHTMLText(from lines: [String]) -> String { + lines.map(unescapedInlineHTMLText) + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func structuralHTMLTag(_ name: String, attributes: [(String, String?)]) -> String { + let attributeText = attributes.compactMap { key, value -> String? in + guard let value = nonEmptyStructuralHTMLAttribute(value) else { return nil } + return #"\#(key)="\#(escapedInlineHTMLAttribute(value))""# + }.joined(separator: " ") + + return attributeText.isEmpty ? "<\(name)>" : "<\(name) \(attributeText)>" + } + + private static func nonEmptyStructuralHTMLAttribute(_ value: String?) -> String? { + guard let value, value.isEmpty == false else { + return nil + } + return value + } + + private static var docmostStructuralHTMLTypes: Set { + ["base-embed", "subpages", "transclusionReference", "transclusionSource"] + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift new file mode 100644 index 0000000..54a4b3d --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TableHTML.swift @@ -0,0 +1,227 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func htmlTableBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + guard isHTMLTableStartLine(lines[index]), + let htmlTable = htmlTableHTML(in: lines, startingAt: index) else { + return nil + } + + let rows = htmlTableRows(from: htmlTable.html) + guard rows.isEmpty == false else { return nil } + + let table = NativeEditorTable(rows: rows) + return ( + NativeEditorBlock( + kind: .table(table), + text: AttributedString(NativeEditorDocument.previewText(for: .table(table))), + alignment: .left, + rawNode: NativeEditorTableNodeFactory.node(from: table) + ), + htmlTable.endIndex + ) + } + + private static func isHTMLTableStartLine(_ line: String) -> Bool { + if htmlTagAttributes(from: line, tagName: "table") != nil { + return true + } + + guard let attrs = htmlTagAttributes(from: line, tagName: "div"), + let className = attrs["class"] else { + return false + } + + return className.split(whereSeparator: \.isWhitespace).contains("tableWrapper") + } + + private static func htmlTableHTML( + in lines: [String], + startingAt index: Array.Index + ) -> (html: String, endIndex: Array.Index)? { + let startsWithWrapper = htmlTagAttributes(from: lines[index], tagName: "div")?["class"]? + .split(whereSeparator: \.isWhitespace) + .contains("tableWrapper") ?? false + var tableLines: [String] = [] + var sawTable = false + var currentIndex = index + + while currentIndex < lines.endIndex { + let line = lines[currentIndex] + tableLines.append(line) + + if firstHTMLTagAttributes(in: line, tagName: "table") != nil { + sawTable = true + } + + if sawTable, containsHTMLClosingTag(in: line, tagName: "table") { + var endIndex = lines.index(after: currentIndex) + if startsWithWrapper, + endIndex < lines.endIndex, + containsHTMLClosingTag(in: lines[endIndex], tagName: "div") { + tableLines.append(lines[endIndex]) + endIndex = lines.index(after: endIndex) + } + return (tableLines.joined(separator: "\n"), endIndex) + } + + currentIndex = lines.index(after: currentIndex) + } + + return nil + } + + private static func htmlTableRows(from html: String) -> [NativeEditorTableRow] { + htmlTableRowBodies(from: html).compactMap { rowHTML in + let cells = htmlTableCells(from: rowHTML) + return cells.isEmpty ? nil : NativeEditorTableRow(cells: cells) + } + } + + private static func htmlTableRowBodies(from html: String) -> [String] { + htmlRegexMatches(pattern: #"]*>(.*?)"#, in: html).compactMap { + htmlRegexString(match: $0, captureIndex: 1, in: html) + } + } + + private static func htmlTableCells(from rowHTML: String) -> [NativeEditorTableCell] { + htmlRegexMatches(pattern: #"<(th|td)\b([^>]*)>(.*?)"#, in: rowHTML) + .prefix(NativeEditorTable.maximumColumnCount) + .compactMap { match in + guard let tagName = htmlRegexString(match: match, captureIndex: 1, in: rowHTML), + let attributeText = htmlRegexString(match: match, captureIndex: 2, in: rowHTML), + let body = htmlRegexString(match: match, captureIndex: 3, in: rowHTML) else { + return nil + } + + return htmlTableCell(tagName: tagName, attributeText: attributeText, body: body) + } + } + + private static func htmlTableCell(tagName: String, attributeText: String, body: String) -> NativeEditorTableCell { + let attrs = docmostInlineHTMLAttributes(from: "<\(tagName)\(attributeText)>") + let paragraphAttrs = htmlTableParagraphAttributes(in: body) + let plainText = htmlTablePlainText(from: body) + + return NativeEditorTableCell( + plainText: plainText, + isHeader: tagName.localizedCaseInsensitiveCompare("th") == .orderedSame, + textAlignment: htmlTableTextAlignment(from: paragraphAttrs), + backgroundColor: nonEmptyHTMLTableAttribute(attrs["data-background-color"]) ?? + htmlStyleValue(named: "background-color", in: attrs["style"]), + backgroundColorName: nonEmptyHTMLTableAttribute(attrs["data-background-color-name"]), + columnSpan: htmlTableSpan(from: attrs["colspan"]), + rowSpan: htmlTableSpan(from: attrs["rowspan"]), + columnWidths: htmlTableColumnWidths(from: attrs) + ) + } + + private static func htmlTableParagraphAttributes(in html: String) -> [String: String] { + guard let match = htmlRegexMatches(pattern: #"]*)>"#, in: html).first, + let attributeText = htmlRegexString(match: match, captureIndex: 1, in: html) else { + return [:] + } + + return docmostInlineHTMLAttributes(from: "") + } + + private static func htmlTableTextAlignment(from attrs: [String: String]) -> NativeEditorTextAlignment? { + let value = nonEmptyHTMLTableAttribute(attrs["align"]) ?? + htmlStyleValue(named: "text-align", in: attrs["style"]) + guard let value else { return nil } + return NativeEditorTextAlignment(rawValue: value.lowercased()) + } + + private static func htmlTablePlainText(from html: String) -> String { + let paragraphSeparated = htmlRegexReplacing( + pattern: #"

\s*]*>"#, + in: html, + with: "\n" + ) + let hardBreakSeparated = htmlRegexReplacing( + pattern: #""#, + in: paragraphSeparated, + with: "\n" + ) + let withoutTags = htmlRegexReplacing(pattern: #"<[^>]+>"#, in: hardBreakSeparated, with: "") + return unescapedInlineHTMLText(withoutTags).trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func htmlTableSpan(from value: String?) -> Int { + max(Int(value ?? "") ?? 1, 1) + } + + private static func htmlTableColumnWidths(from attrs: [String: String]) -> [Int] { + guard let value = nonEmptyHTMLTableAttribute(attrs["colwidth"] ?? attrs["data-colwidth"]) else { + return [] + } + + return value.split { character in + character == "," || character.isWhitespace + } + .compactMap { Int($0) } + } + + private static func htmlStyleValue(named name: String, in style: String?) -> String? { + guard let style else { return nil } + let normalizedName = name.lowercased() + + for declaration in style.split(separator: ";") { + let parts = declaration.split(separator: ":", maxSplits: 1).map { + String($0).trimmingCharacters(in: .whitespacesAndNewlines) + } + guard parts.count == 2, parts[0].lowercased() == normalizedName else { + continue + } + + return parts[1].isEmpty ? nil : parts[1] + } + + return nil + } + + private static func htmlRegexMatches(pattern: String, in text: String) -> [NSTextCheckingResult] { + guard let expression = try? NSRegularExpression( + pattern: pattern, + options: [.caseInsensitive, .dotMatchesLineSeparators] + ) else { + return [] + } + + return expression.matches(in: text, range: NSRange(text.startIndex.. String? { + let range = match.range(at: captureIndex) + guard range.location != NSNotFound, + let textRange = Range(range, in: text) else { + return nil + } + + return String(text[textRange]) + } + + private static func htmlRegexReplacing(pattern: String, in text: String, with replacement: String) -> String { + guard let expression = try? NSRegularExpression( + pattern: pattern, + options: [.caseInsensitive, .dotMatchesLineSeparators] + ) else { + return text + } + + let range = NSRange(text.startIndex.. String? { + guard let value, value.isEmpty == false else { return nil } + return value + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift index 9fa119a..2fa4bcc 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Tables.swift @@ -5,17 +5,32 @@ extension NativeEditorMarkdownParser { in lines: [String], startingAt index: Array.Index ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + if let htmlTable = htmlTableBlock(in: lines, startingAt: index) { + return htmlTable + } + let separatorIndex = lines.index(after: index) guard separatorIndex < lines.endIndex, let headerCells = markdownTableCells(from: lines[index]), - isMarkdownTableSeparatorRow(lines[separatorIndex], columnCount: headerCells.count) + let separatorAlignments = markdownTableSeparatorColumnAlignments( + from: lines[separatorIndex], + columnCount: headerCells.count + ) else { return nil } let columnCount = min(headerCells.count, NativeEditorTable.maximumColumnCount) - var rows = [tableRow(from: headerCells, isHeader: true, columnCount: columnCount)] + let columnAlignments = normalizedTableColumnAlignments(separatorAlignments, columnCount: columnCount) + var rows = [ + tableRow( + from: headerCells, + isHeader: true, + columnCount: columnCount, + columnAlignments: columnAlignments + ) + ] var currentIndex = lines.index(after: separatorIndex) while currentIndex < lines.endIndex, rows.count < NativeEditorTable.maximumRowCount { @@ -27,7 +42,14 @@ extension NativeEditorMarkdownParser { break } - rows.append(tableRow(from: cells, isHeader: false, columnCount: columnCount)) + rows.append( + tableRow( + from: cells, + isHeader: false, + columnCount: columnCount, + columnAlignments: columnAlignments + ) + ) currentIndex = lines.index(after: currentIndex) } @@ -49,7 +71,7 @@ extension NativeEditorMarkdownParser { let columnCount = max(table.columnCount, 1) let rows = [ markdownTableRow(from: headerRow, columnCount: columnCount), - markdownTableSeparatorRow(columnCount: columnCount) + markdownTableSeparatorRow(columnAlignments: tableColumnAlignments(from: table, columnCount: columnCount)) ] + table.rows.dropFirst().map { markdownTableRow(from: $0, columnCount: columnCount) } @@ -60,15 +82,37 @@ extension NativeEditorMarkdownParser { private static func tableRow( from cells: [String], isHeader: Bool, - columnCount: Int + columnCount: Int, + columnAlignments: [NativeEditorTextAlignment?] = [] ) -> NativeEditorTableRow { - NativeEditorTableRow( - cells: normalizedTableCells(cells, columnCount: columnCount).map { - NativeEditorTableCell(plainText: $0, isHeader: isHeader, backgroundColorName: nil) + let normalizedCells = normalizedTableCells(cells, columnCount: columnCount) + return NativeEditorTableRow( + cells: normalizedCells.enumerated().map { offset, cell in + let textAlignment = columnAlignments.indices.contains(offset) ? columnAlignments[offset] : nil + return tableCell(from: cell, isHeader: isHeader, textAlignment: textAlignment) } ) } + private static func tableCell( + from markdown: String, + isHeader: Bool, + textAlignment: NativeEditorTextAlignment? + ) -> NativeEditorTableCell { + let attributedText = inlineText(from: markdown) + let inlineContent = NativeEditorDocument + .inlineContent(from: NativeEditorDocument.inlineNodes(from: attributedText)) + .preservedForTableCell + + return NativeEditorTableCell( + plainText: String(attributedText.characters), + inlineContent: inlineContent, + isHeader: isHeader, + textAlignment: textAlignment, + backgroundColorName: nil + ) + } + private static func normalizedTableCells(_ cells: [String], columnCount: Int) -> [String] { var result = Array(cells.prefix(columnCount)) @@ -83,13 +127,44 @@ extension NativeEditorMarkdownParser { let trimmedLine = line.trimmingCharacters(in: .whitespaces) guard trimmedLine.contains("|") else { return nil } + var cells = splitMarkdownTableCells(from: trimmedLine) + + if trimmedLine.first == "|", cells.first?.isEmpty == true { + cells.removeFirst() + } + + if trimmedLine.last == "|", cells.last?.isEmpty == true { + cells.removeLast() + } + + let trimmedCells = cells.map { $0.trimmingCharacters(in: .whitespaces) } + guard trimmedCells.isEmpty == false, trimmedCells.contains(where: { $0.isEmpty == false }) else { + return nil + } + + return trimmedCells + } + + private static func splitMarkdownTableCells(from line: String) -> [String] { var cells = [""] var isEscaped = false + let codeSpanRanges = markdownCodeSpanRanges(in: line[...], bodyStart: line.startIndex) + var index = line.startIndex + + while index < line.endIndex { + let character = line[index] + defer { index = line.index(after: index) } + + if isInsideMarkdownCodeSpan(index, ranges: codeSpanRanges) { + cells[cells.count - 1].append(character) + continue + } - for character in trimmedLine { if isEscaped { if character == "|" { cells[cells.count - 1].append(character) + } else if character == "\\" { + cells[cells.count - 1].append(character) } else { cells[cells.count - 1].append("\\") cells[cells.count - 1].append(character) @@ -111,32 +186,27 @@ extension NativeEditorMarkdownParser { cells[cells.count - 1].append("\\") } - if trimmedLine.first == "|", cells.first?.isEmpty == true { - cells.removeFirst() - } - - if trimmedLine.last == "|", cells.last?.isEmpty == true { - cells.removeLast() - } - - let trimmedCells = cells.map { $0.trimmingCharacters(in: .whitespaces) } - guard trimmedCells.isEmpty == false, trimmedCells.contains(where: { $0.isEmpty == false }) else { - return nil - } - - return trimmedCells + return cells } private static func isMarkdownTableSeparatorRow(_ line: String, columnCount: Int) -> Bool { + markdownTableSeparatorColumnAlignments(from: line, columnCount: columnCount) != nil + } + + private static func markdownTableSeparatorColumnAlignments( + from line: String, + columnCount: Int + ) -> [NativeEditorTextAlignment?]? { guard columnCount > 0, let cells = markdownTableCells(from: line), cells.count == columnCount else { - return false + return nil } - return cells.allSatisfy(isMarkdownTableSeparatorCell) + guard cells.allSatisfy(isMarkdownTableSeparatorCell) else { return nil } + return cells.map(markdownTableSeparatorAlignment) } private static func isMarkdownTableSeparatorCell(_ text: String) -> Bool { @@ -146,18 +216,202 @@ extension NativeEditorMarkdownParser { return separator.allSatisfy { $0 == "-" } } + private static func markdownTableSeparatorAlignment(from text: String) -> NativeEditorTextAlignment? { + let trimmedText = text.trimmingCharacters(in: .whitespaces) + + switch (trimmedText.first == ":", trimmedText.last == ":") { + case (true, true): + return .center + case (false, true): + return .right + case (true, false): + return .left + case (false, false): + return nil + } + } + private static func markdownTableRow(from row: NativeEditorTableRow, columnCount: Int) -> String { - let cells = normalizedTableCells(row.cells.map(\.plainText), columnCount: columnCount) + let cells = normalizedTableCells(row.cells.map(markdownTableCellContent), columnCount: columnCount) return "| \(cells.map(escapedMarkdownTableCell).joined(separator: " | ")) |" } - private static func markdownTableSeparatorRow(columnCount: Int) -> String { - "| \(Array(repeating: "---", count: columnCount).joined(separator: " | ")) |" + private static func markdownTableCellContent(from cell: NativeEditorTableCell) -> String { + guard + let inlineContent = cell.inlineContent, + tableCellCanExportInlineMarkdown(cell) + else { + return cell.plainText + } + + if tableCellInlineContentContainsUnsafeLink(inlineContent) { + return markdownTableInlineContent(from: inlineContent) + } + + return inlineMarkdown(from: NativeEditorDocument.attributedText(from: inlineContent)) + } + + private static func tableCellCanExportInlineMarkdown(_ cell: NativeEditorTableCell) -> Bool { + guard let preservedContent = cell.preservedContent else { return true } + let hasUnsupportedInlineContent = cell.inlineContent?.contains(where: isUnsupportedInlineContent) ?? false + guard preservedContent.count == 1, + let paragraph = preservedContent.first, + paragraph.type == "paragraph", + hasUnsupportedInlineContent == false else { + return false + } + + let attrs = paragraph.attrs ?? [:] + return attrs.keys.allSatisfy { $0 == "textAlign" } + } + + private static func isUnsupportedInlineContent(_ item: NativeEditorInlineContent) -> Bool { + if case .unsupported = item { + return true + } + + return false + } + + private static func tableCellInlineContentContainsUnsafeLink( + _ inlineContent: [NativeEditorInlineContent] + ) -> Bool { + inlineContent.contains { item in + guard case .text(_, let marks) = item else { return false } + return marks.contains { mark in + guard case .link(let href, _) = mark else { return false } + return href.isEmpty == false && NativeEditorDocument.safeLinkURL(from: href) == nil + } + } + } + + private static func markdownTableInlineContent(from inlineContent: [NativeEditorInlineContent]) -> String { + inlineContent.map(markdownTableInlineContent).joined() + } + + private static func markdownTableInlineContent(from item: NativeEditorInlineContent) -> String { + guard case .text(let text, let marks) = item, + let href = unsafeTableCellLinkHref(from: marks) else { + return inlineMarkdown(from: NativeEditorDocument.attributedText(from: item)) + } + + let nonLinkMarks = marks.filter { mark in + guard case .link = mark else { return true } + return false + } + var segment = AttributedString(text) + NativeEditorDocument.apply(nonLinkMarks, to: &segment) + let label = escapedMarkdownTableLinkLabel(inlineMarkdown(from: segment)) + return "[\(label)](\(markdownTableLinkDestination(from: href)))" + } + + private static func unsafeTableCellLinkHref(from marks: [NativeEditorTextMark]) -> String? { + marks.compactMap { mark -> String? in + guard case .link(let href, _) = mark, + href.isEmpty == false, + NativeEditorDocument.safeLinkURL(from: href) == nil else { + return nil + } + + return href + } + .first + } + + private static func tableColumnAlignments( + from table: NativeEditorTable, + columnCount: Int + ) -> [NativeEditorTextAlignment?] { + (0.. NativeEditorTextAlignment? in + guard row.cells.indices.contains(columnIndex) else { return nil } + return row.cells[columnIndex].textAlignment + } + .first + } + } + + private static func normalizedTableColumnAlignments( + _ alignments: [NativeEditorTextAlignment?], + columnCount: Int + ) -> [NativeEditorTextAlignment?] { + var result = Array(alignments.prefix(columnCount)) + + if result.count < columnCount { + result.append(contentsOf: Array(repeating: nil, count: columnCount - result.count)) + } + + return result + } + + private static func markdownTableSeparatorRow(columnAlignments: [NativeEditorTextAlignment?]) -> String { + let cells = columnAlignments.map(markdownTableSeparatorCell) + return "| \(cells.joined(separator: " | ")) |" + } + + private static func markdownTableSeparatorCell(for alignment: NativeEditorTextAlignment?) -> String { + switch alignment { + case .left: + ":---" + case .center: + ":---:" + case .right: + "---:" + case .justify, nil: + "---" + } } private static func escapedMarkdownTableCell(_ text: String) -> String { - text.replacing("\\", with: "\\\\") - .replacing("|", with: "\\|") + var output = "" + var previousCharacter: Character? + var isInsideAngleWrappedLinkDestination = false + + for character in text { + if character == "<", previousCharacter == "(" { + isInsideAngleWrappedLinkDestination = true + } + + if character == "\\", isInsideAngleWrappedLinkDestination == false { + output += "\\\\" + } else if character == "|" { + output += "\\|" + } else if character == "\n" || character == "\r" { + output += " " + } else { + output.append(character) + } + + if character == ">", isInsideAngleWrappedLinkDestination { + isInsideAngleWrappedLinkDestination = false + } + previousCharacter = character + } + + return output + } + + private static func escapedMarkdownTableLinkLabel(_ text: String) -> String { + text + .replacing("\\", with: "\\\\") + .replacing("[", with: "\\[") + .replacing("]", with: "\\]") + } + + private static func markdownTableLinkDestination(from href: String) -> String { + guard href.contains(where: requiresAngleWrappedMarkdownLinkDestination) else { + return href + } + + return "<\(escapedAngleWrappedMarkdownLinkDestination(href))>" + } + + private static func requiresAngleWrappedMarkdownLinkDestination(_ character: Character) -> Bool { + character.isWhitespace || character == "(" || character == ")" || character == "<" || character == ">" + } + + private static func escapedAngleWrappedMarkdownLinkDestination(_ href: String) -> String { + href .replacing("\n", with: " ") .replacing("\r", with: " ") } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+TextColor.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+TextColor.swift new file mode 100644 index 0000000..5f62089 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+TextColor.swift @@ -0,0 +1,117 @@ +import Foundation +import SwiftUI + +extension NativeEditorMarkdownParser { + struct DocmostTextColorHTML { + var range: Range + var color: String + var bodyMarkdown: String + } + + static func textColorMarkdown( + from run: AttributedString.Runs.Run, + body: String + ) -> String { + guard let color = run[NativeEditorTextColorAttribute.self]?.trimmedNonEmpty else { return body } + + return #"\#(body)"# + } + + static func nextDocmostTextColorHTML(in markdown: Substring) -> DocmostTextColorHTML? { + var searchStart = markdown.startIndex + let codeSpanRanges = markdownCodeSpanRanges(in: markdown, bodyStart: markdown.startIndex) + + while searchStart < markdown.endIndex, + let openRange = markdown[searchStart...].range(of: "") else { + searchStart = openRange.upperBound + continue + } + + let openingTag = String(markdown[openRange.lowerBound...openTagEnd]) + let attrs = docmostInlineHTMLAttributes(from: openingTag) + guard attrs["data-type"] == nil, + attrs["data-comment-id"] == nil, + let color = textColor(from: attrs) else { + searchStart = markdown.index(after: openRange.lowerBound) + continue + } + + let contentStart = markdown.index(after: openTagEnd) + guard let closeRange = matchingCloseSpanRange(in: markdown, bodyStart: contentStart) else { + return nil + } + + return DocmostTextColorHTML( + range: openRange.lowerBound.. String? { + guard let style = attrs["style"] else { return nil } + + return style + .split(separator: ";") + .compactMap { declaration -> String? in + let parts = declaration.split(separator: ":", maxSplits: 1).map(String.init) + guard parts.count == 2, + parts[0].trimmingCharacters(in: .whitespacesAndNewlines) + .localizedCaseInsensitiveCompare("color") == .orderedSame else { + return nil + } + + return parts[1].trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + } + .first + } + + private static func isTextColorHTMLTagBoundary(at index: String.Index, in text: Substring) -> Bool { + index == text.endIndex || text[index].isWhitespace || text[index] == ">" || text[index] == "/" + } +} + +private extension String { + var trimmedNonEmpty: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + var nonEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index 7efd8bc..338944e 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -6,7 +6,14 @@ struct NativeEditorMarkdownInputRule: Equatable { } enum NativeEditorMarkdownParser { + private struct CodeFence { + var marker: Character + var length: Int + var language: String + } + static func blocks(from markdown: String) -> [NativeEditorBlock] { + let markdown = removingLeadingYAMLFrontMatter(from: markdown) let lines = markdown.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) var blocks: [NativeEditorBlock] = [] var index = lines.startIndex @@ -30,6 +37,24 @@ enum NativeEditorMarkdownParser { continue } + if let blockquote = blockquoteBlock(in: lines, startingAt: index) { + blocks.append(blockquote.block) + index = blockquote.endIndex + continue + } + + if let listItem = listItemBlock(in: lines, startingAt: index) { + blocks.append(listItem.block) + index = listItem.endIndex + continue + } + + if let paragraph = paragraphBlock(in: lines, startingAt: index) { + blocks.append(paragraph.block) + index = paragraph.endIndex + continue + } + if let block = block(from: lines[index]) { blocks.append(block) } @@ -39,6 +64,77 @@ enum NativeEditorMarkdownParser { return blocks.isEmpty ? [NativeEditorDocument.emptyBlock()] : blocks } + private static func removingLeadingYAMLFrontMatter(from markdown: String) -> String { + var start = markdown.startIndex + while start < markdown.endIndex, markdown[start].isWhitespace { + start = markdown.index(after: start) + } + + guard start < markdown.endIndex, markdown[start...].hasPrefix("---") else { return markdown } + + let bodyStart = markdown.index(start, offsetBy: 3) + guard let closeRange = markdown[bodyStart...].range(of: "---") else { return markdown } + + var contentStart = closeRange.upperBound + while contentStart < markdown.endIndex, markdown[contentStart].isWhitespace { + contentStart = markdown.index(after: contentStart) + } + + return String(markdown[contentStart...]) + } + + private static func paragraphBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + var paragraphLines: [String] = [] + var currentIndex = index + + while currentIndex < lines.endIndex, + let text = paragraphLineText(in: lines, startingAt: currentIndex) { + paragraphLines.append(text) + currentIndex = lines.index(after: currentIndex) + } + + guard paragraphLines.count > 1 else { return nil } + + return ( + NativeEditorBlock( + kind: .paragraph, + text: multilineParagraphText(from: paragraphLines), + alignment: .left + ), + currentIndex + ) + } + + static func multilineParagraphText(from lines: [String]) -> AttributedString { + lines.enumerated().reduce(into: AttributedString("")) { result, item in + if item.offset > 0 { + result += AttributedString("\n") + } + + result += inlineText(from: item.element) + } + } + + private static func paragraphLineText( + in lines: [String], + startingAt index: Array.Index + ) -> String? { + guard + fencedCodeBlock(in: lines, startingAt: index) == nil, + richBlock(in: lines, startingAt: index) == nil, + tableBlock(in: lines, startingAt: index) == nil, + let block = block(from: lines[index]), + block.kind == .paragraph + else { + return nil + } + + return String(block.text.characters) + } + static func inputRule(from text: String) -> NativeEditorMarkdownInputRule? { if isDivider(text.trimmingCharacters(in: .whitespaces)) { return NativeEditorMarkdownInputRule(kind: .divider, text: "Divider") @@ -48,6 +144,18 @@ enum NativeEditorMarkdownParser { return codeRule } + if let mathBlockRule = mathBlockInputRule(from: text) { + return mathBlockRule + } + + if let detailsRule = detailsInputRule(from: text) { + return detailsRule + } + + if let calloutRule = calloutInputRule(from: text) { + return calloutRule + } + return lineInputRule(from: text) } @@ -85,17 +193,16 @@ enum NativeEditorMarkdownParser { startingAt index: Array.Index ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { let line = lines[index].trimmingCharacters(in: .whitespaces) - guard line.hasPrefix("```") else { return nil } + guard let openingFence = codeFenceOpening(from: line) else { return nil } - let language = String(line.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) var content: [String] = [] var currentIndex = lines.index(after: index) while currentIndex < lines.endIndex { let currentLine = lines[currentIndex] - if currentLine.trimmingCharacters(in: .whitespaces).hasPrefix("```") { + if isCodeFenceClosingLine(currentLine, matching: openingFence) { return ( - codeBlock(language: language, text: content.joined(separator: "\n")), + codeBlock(language: openingFence.language, text: content.joined(separator: "\n")), lines.index(after: currentIndex) ) } @@ -104,7 +211,38 @@ enum NativeEditorMarkdownParser { currentIndex = lines.index(after: currentIndex) } - return (codeBlock(language: language, text: content.joined(separator: "\n")), currentIndex) + return (codeBlock(language: openingFence.language, text: content.joined(separator: "\n")), currentIndex) + } + + private static func codeFenceOpening(from line: String) -> CodeFence? { + guard let marker = line.first, marker == "`" || marker == "~" else { return nil } + + let length = leadingRunLength(of: marker, in: line) + guard length >= 3 else { return nil } + + let languageStartIndex = line.index(line.startIndex, offsetBy: length) + let language = String(line[languageStartIndex...]).trimmingCharacters(in: .whitespacesAndNewlines) + return CodeFence(marker: marker, length: length, language: language) + } + + private static func isCodeFenceClosingLine(_ line: String, matching openingFence: CodeFence) -> Bool { + let line = line.trimmingCharacters(in: .whitespaces) + guard line.first == openingFence.marker else { return false } + + let closingLength = leadingRunLength(of: openingFence.marker, in: line) + guard closingLength >= openingFence.length else { return false } + + let remainingStartIndex = line.index(line.startIndex, offsetBy: closingLength) + return line[remainingStartIndex...].allSatisfy(\.isWhitespace) + } + + private static func leadingRunLength(of marker: Character, in text: String) -> Int { + var length = 0 + for character in text { + guard character == marker else { break } + length += 1 + } + return length } private static func codeBlock(language: String, text: String) -> NativeEditorBlock { @@ -116,10 +254,53 @@ enum NativeEditorMarkdownParser { } private static func codeInputRule(from text: String) -> NativeEditorMarkdownInputRule? { - guard text.hasPrefix("```") else { return nil } + guard let openingFence = codeFenceOpening(from: text) else { return nil } + + return NativeEditorMarkdownInputRule( + kind: .codeBlock(language: openingFence.language.isEmpty ? nil : openingFence.language), + text: "" + ) + } + + private static func mathBlockInputRule(from text: String) -> NativeEditorMarkdownInputRule? { + guard text.hasPrefix("$$$"), text.hasSuffix("$$$") else { return nil } - let language = String(text.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) - return NativeEditorMarkdownInputRule(kind: .codeBlock(language: language.isEmpty ? nil : language), text: "") + let mathText = text + .dropFirst(3) + .dropLast(3) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard mathText.isEmpty == false else { return nil } + + let math = NativeEditorMathBlock(text: mathText) + return NativeEditorMarkdownInputRule(kind: .mathBlock(math), text: math.text) + } + + private static func detailsInputRule(from text: String) -> NativeEditorMarkdownInputRule? { + let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedText == ":::details" else { return nil } + + let details = NativeEditorDetailsBlock(summary: "Details", previewText: "Details", isOpen: true) + return NativeEditorMarkdownInputRule(kind: .details(details), text: details.summary) + } + + private static func calloutInputRule(from text: String) -> NativeEditorMarkdownInputRule? { + guard text.hasPrefix(":::"), text.last?.isWhitespace == true else { return nil } + + let typeText = text + .dropFirst(3) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard typeText.allSatisfy(\.isLetter) else { return nil } + + let style = normalizedCalloutStyle(from: typeText) + let callout = NativeEditorCalloutBlock(style: style, icon: nil, previewText: "Callout") + return NativeEditorMarkdownInputRule(kind: .callout(callout), text: callout.previewText) + } + + private static func normalizedCalloutStyle(from typeText: String) -> String { + let validStyles: Set = ["default", "info", "note", "success", "warning", "danger"] + guard typeText.isEmpty == false else { return "info" } + let lowercasedType = typeText.lowercased() + return validStyles.contains(lowercasedType) ? lowercasedType : "info" } private static func lineInputRule(from text: String) -> NativeEditorMarkdownInputRule? { @@ -141,6 +322,7 @@ enum NativeEditorMarkdownParser { ("# ", .heading(level: 1)), ("- ", .bulletListItem), ("* ", .bulletListItem), + ("+ ", .bulletListItem), ("> ", .blockquote) ] @@ -149,8 +331,8 @@ enum NativeEditorMarkdownParser { } private static func taskInputRule(from text: String) -> NativeEditorMarkdownInputRule? { - let uncheckedPrefixes = ["- [ ] ", "* [ ] ", "[] ", "[ ] "] - let checkedPrefixes = ["- [x] ", "- [X] ", "* [x] ", "* [X] ", "[x] ", "[X] "] + let uncheckedPrefixes = ["- [ ] ", "* [ ] ", "+ [ ] ", "[] ", "[ ] "] + let checkedPrefixes = ["- [x] ", "- [X] ", "* [x] ", "* [X] ", "+ [x] ", "+ [X] ", "[x] ", "[X] "] if let prefix = uncheckedPrefixes.first(where: { text.hasPrefix($0) }) { return NativeEditorMarkdownInputRule( @@ -191,64 +373,6 @@ enum NativeEditorMarkdownParser { ) } - private static func inlineText(from markdown: String) -> AttributedString { - var result = AttributedString("") - var remaining = markdown[...] - - while let inlineDelimiter = nextInlineMathDelimiter(in: remaining) { - let openRange = inlineDelimiter.range - appendMarkdownText( - String(remaining[.. (range: Range, value: String)? { - let singleDollarRange = markdown.range(of: "$") - let doubleDollarRange = markdown.range(of: "$$") - - if let doubleDollarRange, doubleDollarRange.lowerBound == singleDollarRange?.lowerBound { - return (doubleDollarRange, "$$") - } - - if let singleDollarRange { - return (singleDollarRange, "$") - } - - return nil - } - private static func markdownLine(from block: NativeEditorBlock) -> String { let indent = String(repeating: " ", count: block.indentLevel) let plainText = String(block.text.characters) @@ -258,13 +382,17 @@ enum NativeEditorMarkdownParser { case .heading(let level): return "\(String(repeating: "#", count: max(level, 1))) \(text)" case .bulletListItem: - return "\(indent)- \(text)" + return listItemMarkdown(prefix: "\(indent)- ", continuationPrefix: "\(indent) ", text: text) case .orderedListItem(let ordinal): - return "\(indent)\(ordinal). \(text)" + let prefix = "\(indent)\(ordinal). " + let continuationPrefix = indent + String(repeating: " ", count: "\(ordinal). ".count) + return listItemMarkdown(prefix: prefix, continuationPrefix: continuationPrefix, text: text) case .taskListItem(let isChecked): - return "\(indent)- [\(isChecked ? "x" : " ")] \(text)" + let prefix = "\(indent)- [\(isChecked ? "x" : " ")] " + let continuationPrefix = indent + String(repeating: " ", count: "- [ ] ".count) + return listItemMarkdown(prefix: prefix, continuationPrefix: continuationPrefix, text: text) case .blockquote: - return "> \(text)" + return blockquoteMarkdown(from: text) case .codeBlock(let language): return codeMarkdown(language: language, text: plainText) case .divider: @@ -277,15 +405,34 @@ enum NativeEditorMarkdownParser { } private static func codeMarkdown(language: String?, text: String) -> String { - """ - ```\(language ?? "") + let fenceLength = max(3, longestBacktickRunLength(in: text) + 1) + let fence = String(repeating: "`", count: fenceLength) + + return """ + \(fence)\(language ?? "") \(text) - ``` + \(fence) """ } + private static func longestBacktickRunLength(in text: String) -> Int { + var longestRun = 0 + var currentRun = 0 + + for character in text { + if character == "`" { + currentRun += 1 + longestRun = max(longestRun, currentRun) + } else { + currentRun = 0 + } + } + + return longestRun + } + private static func isDivider(_ text: String) -> Bool { - text == "---" || text == "***" + text == "---" || text == "***" || text == "___" } private static func listIndentLevel(from line: String) -> Int { @@ -305,18 +452,31 @@ enum NativeEditorMarkdownParser { return min(columns / 2, 8) } - private static func inlineMarkdown(from text: AttributedString) -> String { + static func inlineMarkdown(from text: AttributedString) -> String { var output = "" for run in text.runs { let runText = String(text[run.range].characters) - if let math = run[NativeEditorMathInlineAttribute.self] { - output += "$\(math.text.replacing("$", with: "\\$"))$" + let runMarkdown: String + if let status = run[NativeEditorStatusAttribute.self] { + runMarkdown = statusMarkdown(from: status) + } else if let math = run[NativeEditorMathInlineAttribute.self] { + runMarkdown = "$\(math.text.replacing("$", with: "\\$"))$" } else if let mention = run[NativeEditorMentionAttribute.self] { - output += mentionMarkdown(from: mention, fallbackText: runText) + runMarkdown = mentionMarkdown(from: mention, fallbackText: runText) } else { - output += inlineRunMarkdown(from: run, text: runText) + let scriptMarkdown = scriptUnderlineMarkdown( + from: run, + body: inlineRunMarkdown(from: run, text: runText) + ) + let coloredMarkdown = textColorMarkdown( + from: run, + body: scriptMarkdown + ) + runMarkdown = highlightMarkdown(from: run, body: coloredMarkdown) } + + output += commentMarkdown(from: run.nativeEditorInlineComments, body: runMarkdown) } return output diff --git a/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift b/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift index 6512c04..0360c01 100644 --- a/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift +++ b/docmostly/Features/Editor/NativeEditorRichBlockPayloads.swift @@ -27,12 +27,42 @@ nonisolated struct NativeEditorTableRow: Equatable, Hashable, Sendable { nonisolated struct NativeEditorTableCell: Equatable, Hashable, Sendable { var plainText: String + var inlineContent: [NativeEditorInlineContent]? + var preservedContent: [ProseMirrorNode]? var isHeader: Bool + var textAlignment: NativeEditorTextAlignment? + var backgroundColor: String? var backgroundColorName: String? var columnWidth: Int? var columnSpan: Int = 1 var rowSpan: Int = 1 var columnWidths: [Int] = [] + + init( + plainText: String, + inlineContent: [NativeEditorInlineContent]? = nil, + preservedContent: [ProseMirrorNode]? = nil, + isHeader: Bool, + textAlignment: NativeEditorTextAlignment? = nil, + backgroundColor: String? = nil, + backgroundColorName: String?, + columnWidth: Int? = nil, + columnSpan: Int = 1, + rowSpan: Int = 1, + columnWidths: [Int] = [] + ) { + self.plainText = plainText + self.inlineContent = inlineContent + self.preservedContent = preservedContent + self.isHeader = isHeader + self.textAlignment = textAlignment + self.backgroundColor = backgroundColor + self.backgroundColorName = backgroundColorName + self.columnWidth = columnWidth + self.columnSpan = columnSpan + self.rowSpan = rowSpan + self.columnWidths = columnWidths + } } nonisolated struct NativeEditorMediaBlock: Equatable, Hashable, Sendable { @@ -119,12 +149,54 @@ nonisolated struct NativeEditorDetailsBlock: Equatable, Hashable, Sendable { var isOpen: Bool } -nonisolated struct NativeEditorColumnsBlock: Equatable, Hashable, Sendable { +nonisolated struct NativeEditorColumnsBlock: Hashable, Sendable { + private static let maximumColumnCount = 5 + var layout: String var widthMode: String var columnCount: Int var previewText: String var columnTexts: [String] = [] + var columnWidths: [Double?] = [] + + var normalizedColumnTexts: [String] { + if columnTexts.isEmpty == false { + return normalizedValues(columnTexts, fallback: "") + } + + let firstColumnText = previewText.trimmingCharacters(in: .whitespacesAndNewlines) + return (0...none) + } + + static func == (lhs: NativeEditorColumnsBlock, rhs: NativeEditorColumnsBlock) -> Bool { + lhs.layout == rhs.layout && + lhs.widthMode == rhs.widthMode && + lhs.previewText == rhs.previewText && + lhs.normalizedColumnTexts == rhs.normalizedColumnTexts && + lhs.normalizedColumnWidths == rhs.normalizedColumnWidths + } + + func hash(into hasher: inout Hasher) { + hasher.combine(layout) + hasher.combine(widthMode) + hasher.combine(previewText) + hasher.combine(normalizedColumnTexts) + hasher.combine(normalizedColumnWidths) + } + + private var normalizedColumnCount: Int { + min(max(columnCount, 1), Self.maximumColumnCount) + } + + private func normalizedValues(_ values: [Value], fallback: Value) -> [Value] { + (0.. Color { + if let backgroundColor = cell.backgroundColor, + let cssBackground = cssBackgroundColor(from: backgroundColor) { + return cssBackground + } + if let backgroundColorName = cell.backgroundColorName, let namedBackground = backgroundColor(for: backgroundColorName) { return namedBackground @@ -33,6 +46,66 @@ enum NativeEditorTableLayout { return cell.isHeader ? Color.secondary.opacity(0.12) : Color.clear } + private static func cssBackgroundColor(from value: String) -> Color? { + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let hexColor = Color(docmostlyHex: trimmedValue) { + return hexColor + } + + guard let components = cssRGBAComponents(from: trimmedValue) else { return nil } + return Color( + red: components.red / 255, + green: components.green / 255, + blue: components.blue / 255, + opacity: components.opacity + ) + } + + nonisolated static func cssRGBAComponents(from value: String) -> CSSRGBAComponents? { + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + let lowercasedValue = trimmedValue.lowercased() + guard lowercasedValue.hasPrefix("rgb(") || lowercasedValue.hasPrefix("rgba("), + let openParen = trimmedValue.firstIndex(of: "("), + let closeParen = trimmedValue.lastIndex(of: ")") else { + return nil + } + + let components = trimmedValue[trimmedValue.index(after: openParen)..= 3, + let red = cssColorComponent(from: components[0]), + let green = cssColorComponent(from: components[1]), + let blue = cssColorComponent(from: components[2]) else { + return nil + } + + let opacity = components.indices.contains(3) ? cssAlphaComponent(from: components[3]) ?? 1 : 1 + return CSSRGBAComponents(red: red, green: green, blue: blue, opacity: opacity) + } + + nonisolated private static func cssColorComponent(from value: String) -> Double? { + if value.hasSuffix("%") { + let percentageText = value.dropLast().trimmingCharacters(in: .whitespacesAndNewlines) + guard let percentage = Double(percentageText) else { return nil } + return min(max(percentage / 100, 0), 1) * 255 + } + + guard let component = Double(value) else { return nil } + return min(max(component, 0), 255) + } + + nonisolated private static func cssAlphaComponent(from value: String) -> Double? { + if value.hasSuffix("%") { + let percentageText = value.dropLast().trimmingCharacters(in: .whitespacesAndNewlines) + guard let percentage = Double(percentageText) else { return nil } + return min(max(percentage / 100, 0), 1) + } + + guard let alpha = Double(value) else { return nil } + return min(max(alpha, 0), 1) + } + private static func backgroundColor(for name: String) -> Color? { switch name.lowercased() { case "blue": diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift index a5b8559..baf1720 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift @@ -22,8 +22,9 @@ extension NativeRichEditorViewModel { return } + let isReplacingSlashCommand = activeSlashCommandQuery != nil document.blocks[index].kind = command.blockKind - if activeSlashCommandQuery != nil { + if isReplacingSlashCommand { document.blocks[index].text = AttributedString("") document.blocks[index].selection = AttributedTextSelection() } @@ -126,15 +127,18 @@ extension NativeRichEditorViewModel { else { return } + let link = NativeEditorLink(href: url.absoluteString, isInternal: false) if document.blocks[index].selection.hasSelectedRanges(in: document.blocks[index].text) { var selection = document.blocks[index].selection document.blocks[index].text.transformAttributes(in: &selection) { attributes in attributes.link = url + attributes[NativeEditorLinkAttribute.self] = link } document.blocks[index].selection = selection } else { document.blocks[index].text.link = url + document.blocks[index].text[NativeEditorLinkAttribute.self] = link } } } @@ -147,10 +151,12 @@ extension NativeRichEditorViewModel { var selection = document.blocks[index].selection document.blocks[index].text.transformAttributes(in: &selection) { attributes in attributes.link = nil + attributes[NativeEditorLinkAttribute.self] = nil } document.blocks[index].selection = selection } else { document.blocks[index].text.link = nil + document.blocks[index].text[NativeEditorLinkAttribute.self] = nil } } } @@ -241,6 +247,7 @@ extension NativeRichEditorViewModel { var activeSlashCommandQuery: String? { guard let index = activeBlockIndex else { return nil } + guard document.blocks[index].kind.allowsSlashCommands else { return nil } let text = String(document.blocks[index].text.characters) guard text.first == "/", text.contains("\n") == false else { @@ -250,3 +257,14 @@ extension NativeRichEditorViewModel { return String(text.dropFirst()).trimmingCharacters(in: .whitespacesAndNewlines) } } + +private extension NativeEditorBlockKind { + var allowsSlashCommands: Bool { + switch self { + case .codeBlock: + false + default: + isEditable + } + } +} diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+History.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+History.swift index 8d16b01..a33dc11 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+History.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+History.swift @@ -86,6 +86,7 @@ extension NativeRichEditorViewModel { if applyingInputRules { applyMarkdownInputRuleIfNeeded() applySmartTypographyIfNeeded() + applyInlineMarkdownInputRuleIfNeeded() } let after = makeHistorySnapshot() diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+Mechanics.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+Mechanics.swift index bea21db..b04649f 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+Mechanics.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+Mechanics.swift @@ -76,6 +76,21 @@ extension NativeRichEditorViewModel { document.blocks[index].selection = AttributedTextSelection() } + func applyInlineMarkdownInputRuleIfNeeded() { + guard + let index = activeBlockIndex, + document.blocks[index].kind.allowsInlineMarkdownInputRules, + let attributedText = NativeEditorMarkdownParser.inlineMarkdownInputRuleText( + from: String(document.blocks[index].text.characters) + ) + else { + return + } + + document.blocks[index].text = attributedText + document.blocks[index].selection = AttributedTextSelection() + } + func applySmartTypographyIfNeeded() { guard let index = activeBlockIndex, document.blocks[index].kind.allowsSmartTypography else { return } @@ -125,4 +140,13 @@ private extension NativeEditorBlockKind { true } } + + var allowsInlineMarkdownInputRules: Bool { + switch self { + case .codeBlock: + false + default: + isEditable + } + } } diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+MediaBlocks.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+MediaBlocks.swift index 78fc183..57cbe96 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+MediaBlocks.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+MediaBlocks.swift @@ -197,14 +197,26 @@ nonisolated extension NativeEditorRichBlockNodeFactory { aspectRatio: String?, to attrs: inout [String: ProseMirrorJSONValue] ) { - if let width = width.flatMap(Int.init) { - attrs["width"] = .int(width) + if let width = width.flatMap(proseMirrorDimension(from:)) { + attrs["width"] = width } - if let height = height.flatMap(Int.init) { - attrs["height"] = .int(height) + if let height = height.flatMap(proseMirrorDimension(from:)) { + attrs["height"] = height } if let aspectRatio = aspectRatio.flatMap(Double.init) { attrs["aspectRatio"] = .double(aspectRatio) } } + + private static func proseMirrorDimension(from value: String) -> ProseMirrorJSONValue? { + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedValue.isEmpty == false else { return nil } + if let intValue = Int(trimmedValue) { + return .int(intValue) + } + if let doubleValue = Double(trimmedValue) { + return .double(doubleValue) + } + return .string(trimmedValue) + } } diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+RichBlocks.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+RichBlocks.swift index 675f3a7..24e2ec5 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+RichBlocks.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+RichBlocks.swift @@ -27,12 +27,19 @@ extension NativeRichEditorViewModel { func updateColumns(blockID: UUID, layout: String, widthMode: String, columnTexts: [String]) { updateRichBlock(blockID: blockID) { block in let normalizedColumnTexts = Self.normalizedColumnTexts(columnTexts) + let existingColumnWidths: [Double?] + if case .columns(let existingColumns) = block.kind { + existingColumnWidths = existingColumns.columnWidths + } else { + existingColumnWidths = [] + } let columns = NativeEditorColumnsBlock( layout: layout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "two_equal" : layout, widthMode: widthMode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "normal" : widthMode, columnCount: normalizedColumnTexts.count, previewText: normalizedColumnTexts.joined(separator: " "), - columnTexts: normalizedColumnTexts + columnTexts: normalizedColumnTexts, + columnWidths: Self.normalizedColumnWidths(existingColumnWidths, count: normalizedColumnTexts.count) ) block.kind = .columns(columns) block.text = AttributedString(columns.previewText) @@ -149,6 +156,12 @@ extension NativeRichEditorViewModel { let limitedTexts = Array(columnTexts.prefix(4)) return limitedTexts.isEmpty ? [""] : limitedTexts } + + private static func normalizedColumnWidths(_ columnWidths: [Double?], count: Int) -> [Double?] { + (0.. ProseMirrorNode { - let columnTexts = normalizedColumnTexts(from: columns) + let columnTexts = columns.normalizedColumnTexts + let columnWidths = columns.normalizedColumnWidths return ProseMirrorNode( type: "columns", attrs: [ "layout": .string(columns.layout), "widthMode": .string(columns.widthMode) ], - content: columnTexts.map(columnNode(text:)) + content: zip(columnTexts, columnWidths).map { columnNode(text: $0.0, width: $0.1) } ) } @@ -292,11 +306,11 @@ nonisolated enum NativeEditorRichBlockNodeFactory { if let sizeInBytes = diagram.sizeInBytes { attrs["size"] = .int(sizeInBytes) } - if let width = diagram.width.flatMap(Int.init) { - attrs["width"] = .int(width) + if let width = diagram.width.flatMap(proseMirrorDiagramDimension(from:)) { + attrs["width"] = width } - if let height = diagram.height.flatMap(Int.init) { - attrs["height"] = .int(height) + if let height = diagram.height.flatMap(proseMirrorDiagramDimension(from:)) { + attrs["height"] = height } if let aspectRatio = diagram.aspectRatio.flatMap(Double.init) { attrs["aspectRatio"] = .double(aspectRatio) @@ -315,21 +329,28 @@ nonisolated enum NativeEditorRichBlockNodeFactory { ) } - private static func columnNode(text: String) -> ProseMirrorNode { + private static func columnNode(text: String, width: Double?) -> ProseMirrorNode { ProseMirrorNode( type: "column", - attrs: ["width": .int(1)], + attrs: ["width": proseMirrorNumber(from: width ?? 1)], content: [paragraphNode(text)] ) } - private static func normalizedColumnTexts(from columns: NativeEditorColumnsBlock) -> [String] { - if columns.columnTexts.isEmpty == false { - return Array(columns.columnTexts.prefix(max(columns.columnCount, 1))) + private static func proseMirrorNumber(from value: Double) -> ProseMirrorJSONValue { + if value.rounded() == value, let intValue = Int(exactly: value) { + return .int(intValue) } - let columnCount = max(columns.columnCount, 1) - let firstColumnText = columns.previewText.trimmingCharacters(in: .whitespacesAndNewlines) - return (0.. ProseMirrorJSONValue? { + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedValue.isEmpty == false else { return nil } + if let number = Double(trimmedValue) { + return proseMirrorNumber(from: number) + } + return .string(trimmedValue) } } diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+Tables.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+Tables.swift index 38f5f3f..2131186 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+Tables.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+Tables.swift @@ -9,6 +9,8 @@ extension NativeRichEditorViewModel { } table.rows[rowIndex].cells[columnIndex].plainText = text + table.rows[rowIndex].cells[columnIndex].inlineContent = nil + table.rows[rowIndex].cells[columnIndex].preservedContent = nil } } @@ -128,13 +130,17 @@ nonisolated enum NativeEditorTableNodeFactory { ProseMirrorNode( type: cell.isHeader ? "tableHeader" : "tableCell", attrs: cellAttrs(from: cell), - content: [paragraphNode(cell.plainText)] + content: cell.preservedContent ?? [paragraphNode(from: cell)] ) } private static func cellAttrs(from cell: NativeEditorTableCell) -> [String: ProseMirrorJSONValue]? { var attrs: [String: ProseMirrorJSONValue] = [:] + if let backgroundColor = cell.backgroundColor, backgroundColor.isEmpty == false { + attrs["backgroundColor"] = .string(backgroundColor) + } + if let backgroundColorName = cell.backgroundColorName, backgroundColorName.isEmpty == false { attrs["backgroundColorName"] = .string(backgroundColorName.lowercased()) } @@ -158,10 +164,17 @@ nonisolated enum NativeEditorTableNodeFactory { return attrs.isEmpty ? nil : attrs } - private static func paragraphNode(_ text: String) -> ProseMirrorNode { + private static func paragraphNode(from cell: NativeEditorTableCell) -> ProseMirrorNode { ProseMirrorNode( type: "paragraph", - content: NativeEditorDocument.inlineNodes(from: AttributedString(text)) + attrs: paragraphAttrs(from: cell), + content: cell.inlineContent.map(NativeEditorDocument.inlineNodes(from:)) ?? + NativeEditorDocument.inlineNodes(from: AttributedString(cell.plainText)) ) } + + private static func paragraphAttrs(from cell: NativeEditorTableCell) -> [String: ProseMirrorJSONValue]? { + guard let textAlignment = cell.textAlignment else { return nil } + return ["textAlign": .string(textAlignment.rawValue)] + } } diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel.swift b/docmostly/Features/Editor/NativeRichEditorViewModel.swift index 2da1f61..bc94c14 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel.swift @@ -113,7 +113,9 @@ final class NativeRichEditorViewModel { } var filteredSlashCommands: [NativeEditorCommand] { - let matches = NativeEditorCommand.allCases.compactMap { command in + guard let slashCommandQuery = activeSlashCommandQuery else { return [] } + + let matches = NativeEditorCommand.slashMenuCases.compactMap { command in command.matchPriority(query: slashCommandQuery).map { priority in (command: command, priority: priority) } diff --git a/docmostlyTests/Editor/NativeEditorBlockquoteFidelityTests.swift b/docmostlyTests/Editor/NativeEditorBlockquoteFidelityTests.swift new file mode 100644 index 0000000..a31549d --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorBlockquoteFidelityTests.swift @@ -0,0 +1,40 @@ +import Foundation +import SwiftUI +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorBlockquoteFidelityTests { + @Test func preservesAdditionalBlockquoteContentWhenReencodingNativeEdits() throws { + let trailingParagraph = ProseMirrorNode( + type: "paragraph", + attrs: ["id": .string("quote-paragraph-2")], + content: [ProseMirrorNode(type: "text", text: "Follow-up quote")] + ) + let original = ProseMirrorDocument(content: [ + ProseMirrorNode( + type: "blockquote", + attrs: ["dataKind": .string("annotation")], + content: [ + ProseMirrorNode( + type: "paragraph", + attrs: ["id": .string("quote-paragraph-1")], + content: [ProseMirrorNode(type: "text", text: "Original quote")] + ), + trailingParagraph + ] + ) + ]) + var document = NativeEditorDocument(proseMirrorDocument: original) + + document.blocks[0].text = AttributedString("Edited quote") + + let encodedBlockquote = try #require(document.proseMirrorDocument.content.first) + let encodedContent = try #require(encodedBlockquote.content) + #expect(encodedBlockquote.attrs?["dataKind"] == .string("annotation")) + #expect(encodedContent.count == 2) + #expect(encodedContent[0].attrs?["id"] == .string("quote-paragraph-1")) + #expect(encodedContent[0].content?.first?.text == "Edited quote") + #expect(encodedContent[1] == trailingParagraph) + } +} diff --git a/docmostlyTests/Editor/NativeEditorBlockquoteMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorBlockquoteMarkdownTests.swift new file mode 100644 index 0000000..afa8472 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorBlockquoteMarkdownTests.swift @@ -0,0 +1,25 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorBlockquoteMarkdownTests { + @Test func blockquoteMarkdownExportPrefixesEveryLine() throws { + let block = NativeEditorBlock( + kind: .blockquote, + text: AttributedString("Quote line one\nQuote line two"), + alignment: .left + ) + + let markdown = NativeEditorMarkdownParser.markdown(from: [block]) + + #expect(markdown == """ + > Quote line one + > Quote line two + """) + + let importedBlock = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + #expect(importedBlock.kind == .blockquote) + #expect(String(importedBlock.text.characters) == "Quote line one\nQuote line two") + } +} diff --git a/docmostlyTests/Editor/NativeEditorCRDTCoordinatorReuseTests.swift b/docmostlyTests/Editor/NativeEditorCRDTCoordinatorReuseTests.swift index 19720bc..eab477c 100644 --- a/docmostlyTests/Editor/NativeEditorCRDTCoordinatorReuseTests.swift +++ b/docmostlyTests/Editor/NativeEditorCRDTCoordinatorReuseTests.swift @@ -24,19 +24,17 @@ struct NativeEditorCRDTCoordinatorReuseTests { } @MainActor +@Suite(.serialized) struct CRDTEngineAttachmentTests { - @Test func crdtAttachmentDoesNothingWithoutFactory() async { - let appState = AppState() - let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") - - await NativeEditorCRDTDocumentEngineAttachment.attachIfAvailable( - to: viewModel, - appState: appState + @Test func appStateDoesNotCreateCRDTEngineWithoutFactory() async throws { + let appState = AppState(crdtDocumentEngineFactory: nil) + let engine = try await appState.makeCRDTDocumentEngine( + pageID: "page-1", + title: "Page", + document: NativeEditorDocument() ) - #expect(viewModel.usesCRDTDocumentEngine == false) - #expect(viewModel.collaborationSession().syncDriver == nil) - #expect(viewModel.realtimeStatus == .disconnected) + #expect(engine == nil) } @Test func crdtAttachmentConfiguresFactoryEngineBeforeCollaborationSession() async throws { diff --git a/docmostlyTests/Editor/NativeEditorCodeBlockAttributeTests.swift b/docmostlyTests/Editor/NativeEditorCodeBlockAttributeTests.swift new file mode 100644 index 0000000..4437bbd --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorCodeBlockAttributeTests.swift @@ -0,0 +1,28 @@ +import Foundation +import SwiftUI +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorCodeBlockAttributeTests { + @Test func preservesCodeBlockAttributesWhenReencodingNativeEdits() throws { + let original = ProseMirrorDocument(content: [ + ProseMirrorNode( + type: "codeBlock", + attrs: [ + "id": .string("code-block-1"), + "language": .string("swift") + ], + content: [ProseMirrorNode(type: "text", text: "let value = true")] + ) + ]) + var document = NativeEditorDocument(proseMirrorDocument: original) + + document.blocks[0].text = AttributedString("let value = false") + + let encodedBlock = try #require(document.proseMirrorDocument.content.first) + #expect(encodedBlock.attrs?["id"] == .string("code-block-1")) + #expect(encodedBlock.attrs?["language"] == .string("swift")) + #expect(encodedBlock.content?.first?.text == "let value = false") + } +} diff --git a/docmostlyTests/Editor/NativeEditorCodeBlockMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorCodeBlockMarkdownTests.swift new file mode 100644 index 0000000..373a2a1 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorCodeBlockMarkdownTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorCodeBlockMarkdownTests { + @Test func codeBlockInputRuleUsesWholeOpeningFenceForLanguage() throws { + let rule = try #require(NativeEditorMarkdownParser.inputRule(from: "````swift")) + + #expect(rule.kind == .codeBlock(language: "swift")) + #expect(rule.text.isEmpty) + } + + @Test func codeBlockMarkdownExportUsesLongerFenceWhenBodyContainsBacktickFence() throws { + let code = """ + ```swift + let value = true + ``` + """ + let block = NativeEditorBlock( + kind: .codeBlock(language: "markdown"), + text: AttributedString(code), + alignment: .left + ) + + let markdown = NativeEditorMarkdownParser.markdown(from: [block]) + + #expect(markdown == """ + ````markdown + ```swift + let value = true + ``` + ```` + """) + + let importedBlock = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + guard case .codeBlock(let language) = importedBlock.kind else { + Issue.record("Expected Markdown to reimport as a code block.") + return + } + + #expect(language == "markdown") + #expect(String(importedBlock.text.characters) == code) + } +} diff --git a/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift b/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift new file mode 100644 index 0000000..7157898 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorContainerHTMLFidelityTests.swift @@ -0,0 +1,146 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorContainerHTMLFidelityTests { + @Test func importsDocmostCalloutDetailsAndMathHTMLAsNativeBlocks() throws { + let markdown = """ +
+ Launch checklist +
+
+ Release notes +
+ Ship build +
+
+
E = mc^2
+ """ + let blocks = NativeEditorMarkdownParser.blocks(from: markdown) + + try #require(blocks.count == 3) + + guard case .callout(let callout) = blocks[0].kind else { + Issue.record("Expected Docmost callout HTML to import as a native callout block.") + return + } + #expect(callout.style == "warning") + #expect(callout.icon == "rocket") + #expect(callout.previewText == "Launch checklist") + #expect(blocks[0].rawNode?.type == "callout") + #expect(blocks[0].rawNode?.attrs?["type"] == .string("warning")) + #expect(blocks[0].rawNode?.attrs?["icon"] == .string("rocket")) + + guard case .details(let details) = blocks[1].kind else { + Issue.record("Expected Docmost details HTML to import as a native details block.") + return + } + #expect(details.summary == "Release notes") + #expect(details.previewText == "Ship build") + #expect(details.isOpen == true) + #expect(blocks[1].rawNode?.type == "details") + #expect(blocks[1].rawNode?.attrs?["open"] == .bool(true)) + + guard case .mathBlock(let math) = blocks[2].kind else { + Issue.record("Expected Docmost math block HTML to import as a native math block.") + return + } + #expect(math.text == "E = mc^2") + #expect(blocks[2].rawNode?.type == "mathBlock") + #expect(blocks[2].rawNode?.attrs?["text"] == .string("E = mc^2")) + } + + @Test func exportsNativeCalloutDetailsAsDocmostHTMLAndMathAsFenceMarkdown() { + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [ + NativeEditorBlock( + kind: .callout(NativeEditorCalloutBlock( + style: "warning", + icon: "rocket", + previewText: "Launch checklist" + )), + text: AttributedString("Launch checklist"), + alignment: .left + ), + NativeEditorBlock( + kind: .details(NativeEditorDetailsBlock( + summary: "Release notes", + previewText: "Ship build", + isOpen: true + )), + text: AttributedString("Release notes"), + alignment: .left + ), + NativeEditorBlock( + kind: .mathBlock(NativeEditorMathBlock(text: "E = mc^2")), + text: AttributedString("E = mc^2"), + alignment: .left + ) + ]) + viewModel.resetEditingHistory() + + #expect(viewModel.markdownForDocument() == """ +
+ Launch checklist +
+
+ Release notes +
+ Ship build +
+
+ $$ + E = mc^2 + $$ + """) + } + + @Test func exportsSingleLineIconlessNativeCalloutAsDocmostFenceMarkdown() { + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [ + NativeEditorBlock( + kind: .callout(NativeEditorCalloutBlock( + style: "info", + icon: nil, + previewText: "Check migration plan" + )), + text: AttributedString("Check migration plan"), + alignment: .left + ) + ]) + viewModel.resetEditingHistory() + + #expect(viewModel.markdownForDocument() == """ + :::info + Check migration plan + ::: + """) + } + + @Test func exportsIconlessNativeCalloutAsDocmostFenceMarkdown() { + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [ + NativeEditorBlock( + kind: .callout(NativeEditorCalloutBlock( + style: "warning", + icon: nil, + previewText: """ + Check migration plan + Confirm rollback owner + """ + )), + text: AttributedString("Check migration plan"), + alignment: .left + ) + ]) + viewModel.resetEditingHistory() + + #expect(viewModel.markdownForDocument() == """ + :::warning + Check migration plan + Confirm rollback owner + ::: + """) + } +} diff --git a/docmostlyTests/Editor/NativeEditorDividerMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorDividerMarkdownTests.swift new file mode 100644 index 0000000..9c8e739 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorDividerMarkdownTests.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftUI +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorDividerMarkdownTests { + @Test func markdownImportSupportsUnderscoreDivider() throws { + let block = try #require(NativeEditorMarkdownParser.blocks(from: "___").first) + + #expect(block.kind == .divider) + #expect(String(block.text.characters) == "Divider") + } + + @Test func markdownInputRuleSupportsUnderscoreDividerShortcut() { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(""), alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.document.blocks[0].text = AttributedString("___") + viewModel.handleDocumentChanged() + + #expect(viewModel.document.blocks[0].kind == .divider) + #expect(String(viewModel.document.blocks[0].text.characters) == "Divider") + #expect(viewModel.markdownForDocument() == "---") + } + + private func configuredViewModel(blocks: [NativeEditorBlock]) -> NativeRichEditorViewModel { + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: blocks) + viewModel.resetEditingHistory() + return viewModel + } +} diff --git a/docmostlyTests/Editor/NativeEditorEditableBlockIDTests.swift b/docmostlyTests/Editor/NativeEditorEditableBlockIDTests.swift new file mode 100644 index 0000000..fe50208 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorEditableBlockIDTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorEditableBlockIDTests { + @Test func preservesEditableBlockIDsAfterNativeEdits() throws { + let data = Data(""" + { + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { "level": 2, "id": "heading-deep-link", "textAlign": "center" }, + "content": [{ "type": "text", "text": "Plan" }] + }, + { + "type": "paragraph", + "attrs": { "id": "paragraph-node" }, + "content": [{ "type": "text", "text": "Body" }] + } + ] + } + """.utf8) + var document = try NativeEditorDocument(proseMirrorJSONData: data) + + document.blocks[0].text = AttributedString("Updated plan") + document.blocks[1].text = AttributedString("Updated body") + + let encodedData = try document.proseMirrorJSONData() + let root = try #require(JSONSerialization.jsonObject(with: encodedData) as? [String: Any]) + let content = try #require(root["content"] as? [[String: Any]]) + #expect(content.count == 2) + let headingNode = try #require(content.first) + let paragraphNode = try #require(content.dropFirst().first) + let headingAttrs = try #require(headingNode["attrs"] as? [String: Any]) + let paragraphAttrs = try #require(paragraphNode["attrs"] as? [String: Any]) + let headingContent = try #require(headingNode["content"] as? [[String: Any]]) + let paragraphContent = try #require(paragraphNode["content"] as? [[String: Any]]) + + #expect(headingAttrs["id"] as? String == "heading-deep-link") + #expect(headingAttrs["level"] as? Int == 2) + #expect(headingAttrs["textAlign"] as? String == "center") + #expect(headingContent.first?["text"] as? String == "Updated plan") + #expect(paragraphAttrs["id"] as? String == "paragraph-node") + #expect(paragraphContent.first?["text"] as? String == "Updated body") + } +} diff --git a/docmostlyTests/Editor/NativeEditorHTMLTagMatchingTests.swift b/docmostlyTests/Editor/NativeEditorHTMLTagMatchingTests.swift new file mode 100644 index 0000000..4a00474 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorHTMLTagMatchingTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorHTMLTagMatchingTests { + @Test func htmlTagNameMatchingIsLocaleIndependent() { + let turkish = Locale(identifier: "tr_TR") + + #expect(NativeEditorMarkdownParser.htmlTagNameMatches("DIV", "div", locale: turkish)) + #expect(NativeEditorMarkdownParser.htmlTagNameMatches("IMG", "img", locale: turkish)) + #expect(NativeEditorMarkdownParser.htmlTagNameMatches("SPAN", "span", locale: turkish)) + } + + @Test func matchingCloseSpanRangeIgnoresSpanCandidatesInsideMarkdownCode() throws { + let markdown = ##"`` "## + + ##"nested outer"## + let openingTagEnd = try #require(markdown.firstIndex(of: ">")) + let bodyStart = markdown.index(after: openingTagEnd) + + let closeRange = try #require( + NativeEditorMarkdownParser.matchingCloseSpanRange(in: markdown[...], bodyStart: bodyStart) + ) + let expectedCloseRange = try #require(markdown.range(of: "", options: .backwards)) + + #expect(closeRange == expectedCloseRange) + } + + @Test func htmlTagDepthDeltaIgnoresTagLookalikesInsideQuotedAttributes() { + let line = #"
x
"# + + #expect(NativeEditorMarkdownParser.htmlTagDepthDelta(in: line, tagName: "div") == 0) + } + + @Test func containsHTMLClosingTagIgnoresTagLookalikesInsideQuotedAttributes() { + let line = #"x"# + + #expect(NativeEditorMarkdownParser.containsHTMLClosingTag(in: line, tagName: "div") == false) + } +} diff --git a/docmostlyTests/Editor/NativeEditorHighlightMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorHighlightMarkdownTests.swift new file mode 100644 index 0000000..bbf0085 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorHighlightMarkdownTests.swift @@ -0,0 +1,67 @@ +import Foundation +import SwiftUI +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorHighlightMarkdownTests { + @Test func markdownExportPreservesDocmostHighlightMarkAsHTML() { + var text = AttributedString("Review ") + var highlighted = AttributedString("important") + highlighted[NativeEditorHighlightColorAttribute.self] = "#FEF3C7" + highlighted[NativeEditorHighlightColorNameAttribute.self] = "yellow" + highlighted.backgroundColor = Color(docmostlyHex: "#FEF3C7") + text += highlighted + text += AttributedString(" today") + + let block = NativeEditorBlock(kind: .paragraph, text: text, alignment: .left) + let expectedMarkdown = ##"Review important today"## + + #expect(NativeEditorMarkdownParser.markdown(from: [block]) == expectedMarkdown) + } + + @Test func markdownImportPreservesDocmostHighlightMarkAsProseMirrorMark() throws { + let markdown = ##"Review "## + + "important **copy** today" + let block = try #require(NativeEditorMarkdownParser.blocks( + from: markdown + ).first) + + let highlightedRuns = block.text.runs.filter { run in + run[NativeEditorHighlightColorAttribute.self] == "#DCFCE7" && + run[NativeEditorHighlightColorNameAttribute.self] == "green" + } + #expect(highlightedRuns.count == 2) + + let boldRun = try #require(highlightedRuns.last) + #expect(String(block.text[boldRun.range].characters) == "copy") + #expect(boldRun.inlinePresentationIntent?.contains(.stronglyEmphasized) == true) + + let inlineNodes = try #require(NativeEditorDocument.node(from: block).content) + let highlightMark = ProseMirrorMark( + type: "highlight", + attrs: ["color": .string("#DCFCE7"), "colorName": .string("green")] + ) + #expect(inlineNodes.contains { $0.marks?.contains(highlightMark) == true }) + } + + @Test func markdownImportIgnoresHighlightHTMLInsideCodeSpans() throws { + let markdown = ##"Keep `literal` then "## + + ##"real"## + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + let codeRun = try #require(block.text.runs.first { run in + String(block.text[run.range].characters) == ##"literal"## + }) + #expect(codeRun.inlinePresentationIntent?.contains(.code) == true) + #expect(codeRun[NativeEditorHighlightColorAttribute.self] == nil) + + let highlightRun = try #require(block.text.runs.first { run in + String(block.text[run.range].characters) == "real" + }) + #expect(highlightRun[NativeEditorHighlightColorAttribute.self] == "#DCFCE7") + #expect(highlightRun[NativeEditorHighlightColorNameAttribute.self] == "green") + } +} diff --git a/docmostlyTests/Editor/NativeEditorInlineCodeMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorInlineCodeMarkdownTests.swift new file mode 100644 index 0000000..9c39663 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorInlineCodeMarkdownTests.swift @@ -0,0 +1,38 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorInlineCodeMarkdownTests { + @Test func markdownImportPreservesDoubleBacktickCodeSpans() throws { + let markdown = "Use ``let `tick` = true`` today" + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + #expect(String(block.text.characters) == "Use let `tick` = true today") + + let inlineNodes = NativeEditorDocument.inlineNodes(from: block.text) + let codeNode = try #require(inlineNodes.first { $0.text == "let `tick` = true" }) + #expect(codeNode.marks?.contains(ProseMirrorMark(type: "code")) == true) + #expect(NativeEditorMarkdownParser.markdown(from: [block]) == markdown) + } + + @Test func markdownExportUsesLongerBacktickRunForCodeContainingDoubleBackticks() throws { + var text = AttributedString("Use ") + var code = AttributedString("value `` fence") + code.inlinePresentationIntent = .code + text += code + text += AttributedString(" today") + let block = NativeEditorBlock(kind: .paragraph, text: text, alignment: .left) + + let markdown = NativeEditorMarkdownParser.markdown(from: [block]) + + #expect(markdown == "Use ```value `` fence``` today") + + let importedBlock = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + #expect(String(importedBlock.text.characters) == "Use value `` fence today") + let codeNode = try #require(NativeEditorDocument.inlineNodes(from: importedBlock.text).first { + $0.text == "value `` fence" + }) + #expect(codeNode.marks?.contains(ProseMirrorMark(type: "code")) == true) + } +} diff --git a/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift b/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift index aa1b339..bac41b2 100644 --- a/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift +++ b/docmostlyTests/Editor/NativeEditorInlineCommentMarkTests.swift @@ -2,7 +2,80 @@ import Foundation import Testing @testable import docmostly +@MainActor struct NativeEditorInlineCommentMarkTests { + @Test func markdownExportPreservesDocmostInlineCommentSpan() { + var text = AttributedString("Needs review") + text.setNativeEditorInlineComments([ + NativeEditorInlineCommentMark(commentID: "comment-1", isResolved: false) + ]) + + let block = NativeEditorBlock(kind: .paragraph, text: text, alignment: .left) + + #expect( + NativeEditorMarkdownParser.markdown(from: [block]) == + #"Needs review"# + ) + } + + @Test func markdownImportPreservesDocmostInlineCommentSpan() throws { + let markdown = #"Review this **copy** today"# + let block = try #require( + NativeEditorMarkdownParser.blocks(from: markdown).first + ) + + let comment = NativeEditorInlineCommentMark(commentID: "comment-1", isResolved: true) + let markedRuns = block.text.runs.filter { $0.nativeEditorInlineComments == [comment] } + + #expect(markedRuns.count == 2) + let boldRun = try #require(markedRuns.last) + #expect(String(block.text[boldRun.range].characters) == "copy") + #expect(boldRun.inlinePresentationIntent?.contains(.stronglyEmphasized) == true) + + let inlineNodes = try #require(NativeEditorDocument(blocks: [block]).proseMirrorDocument.content.first?.content) + let commentMark = ProseMirrorMark( + type: "comment", + attrs: ["commentId": .string("comment-1"), "resolved": .bool(true)] + ) + #expect(inlineNodes.contains { $0.marks?.contains(commentMark) == true }) + } + + @Test func markdownImportTreatsExplicitFalseResolvedAttributeAsUnresolved() throws { + let markdown = #"Review this copy today"# + let block = try #require( + NativeEditorMarkdownParser.blocks(from: markdown).first + ) + + let comment = NativeEditorInlineCommentMark(commentID: "comment-1", isResolved: false) + let markedRun = try #require(block.text.runs.first { $0.nativeEditorInlineComments == [comment] }) + #expect(String(block.text[markedRun.range].characters) == "this copy") + + let inlineNodes = try #require(NativeEditorDocument(blocks: [block]).proseMirrorDocument.content.first?.content) + let commentMark = ProseMirrorMark( + type: "comment", + attrs: ["commentId": .string("comment-1"), "resolved": .bool(false)] + ) + #expect(inlineNodes.contains { $0.marks?.contains(commentMark) == true }) + } + + @Test func markdownImportPreservesCommentBodyContainingLiteralSpanText() throws { + let markdown = #"Review use `` text today"# + let block = try #require( + NativeEditorMarkdownParser.blocks(from: markdown).first + ) + + let comment = NativeEditorInlineCommentMark(commentID: "comment-1", isResolved: false) + let markedRuns = block.text.runs.filter { $0.nativeEditorInlineComments == [comment] } + #expect(markedRuns.isEmpty == false) + + let markedText = markedRuns.reduce(into: "") { text, run in + text += String(block.text[run.range].characters) + } + #expect(markedText == "use text") + } + @Test func preservesOverlappingInlineCommentMarks() throws { let data = Data(""" { @@ -48,4 +121,53 @@ struct NativeEditorInlineCommentMarkTests { ProseMirrorMark(type: "comment", attrs: ["commentId": .string("comment-2"), "resolved": .bool(true)]) ]) } + + @Test func preservesInlineCommentMarksOnMentionAtoms() throws { + let data = Data(""" + { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Ask " }, + { + "type": "mention", + "attrs": { + "id": "mention-1", + "label": "Taylor", + "entityType": "user", + "entityId": "user-1" + }, + "marks": [ + { + "type": "comment", + "attrs": { "commentId": "comment-1", "resolved": false } + } + ] + }, + { "type": "text", "text": " today" } + ] + } + ] + } + """.utf8) + + let document = try NativeEditorDocument(proseMirrorJSONData: data) + let block = try #require(document.blocks.first) + let mentionRun = try #require(block.text.runs.first { run in + run[NativeEditorMentionAttribute.self]?.identifier == "mention-1" + }) + + #expect(mentionRun.nativeEditorInlineComments == [ + NativeEditorInlineCommentMark(commentID: "comment-1", isResolved: false) + ]) + + let mentionNode = try #require( + document.proseMirrorDocument.content.first?.content?.first { $0.type == "mention" } + ) + #expect(mentionNode.marks == [ + ProseMirrorMark(type: "comment", attrs: ["commentId": .string("comment-1"), "resolved": .bool(false)]) + ]) + } } diff --git a/docmostlyTests/Editor/NativeEditorInlineMarkdownShortcutTests.swift b/docmostlyTests/Editor/NativeEditorInlineMarkdownShortcutTests.swift new file mode 100644 index 0000000..1c38d02 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorInlineMarkdownShortcutTests.swift @@ -0,0 +1,37 @@ +import Foundation +import SwiftUI +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorInlineMarkdownShortcutTests { + @Test func markdownInputRuleSupportsDocmostUnderscoreMarkShortcuts() throws { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(""), alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.document.blocks[0].text = AttributedString("Use __strong__ and _emphasis_") + viewModel.handleDocumentChanged() + + #expect(String(viewModel.document.blocks[0].text.characters) == "Use strong and emphasis") + + let inlineNodes = try #require(viewModel.document.proseMirrorDocument.content.first?.content) + #expect(inlineNodes.contains { + $0.text == "strong" && $0.marks?.contains(ProseMirrorMark(type: "bold")) == true + }) + #expect(inlineNodes.contains { + $0.text == "emphasis" && $0.marks?.contains(ProseMirrorMark(type: "italic")) == true + }) + + viewModel.undo() + + #expect(String(viewModel.document.blocks[0].text.characters).isEmpty) + } + + private func configuredViewModel(blocks: [NativeEditorBlock]) -> NativeRichEditorViewModel { + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: blocks) + viewModel.resetEditingHistory() + return viewModel + } +} diff --git a/docmostlyTests/Editor/NativeEditorJSCRDTRuntimeSourceTests.swift b/docmostlyTests/Editor/NativeEditorJSCRDTRuntimeSourceTests.swift index eb448d6..20fedf6 100644 --- a/docmostlyTests/Editor/NativeEditorJSCRDTRuntimeSourceTests.swift +++ b/docmostlyTests/Editor/NativeEditorJSCRDTRuntimeSourceTests.swift @@ -200,8 +200,8 @@ struct NativeEditorJSCRDTRuntimeSourceTests { #expect(selection.anchor.targetName == NativeEditorCollaborationDocument.yjsFragmentName) #expect(selection.head.targetName == NativeEditorCollaborationDocument.yjsFragmentName) - #expect(selection.anchor.type.client != 0) - #expect(selection.head.type.client != 0) + #expect(selection.anchor.assoc == 0) + #expect(selection.head.assoc == 0) } private static let runtimeSource = """ diff --git a/docmostlyTests/Editor/NativeEditorLinkFidelityTests.swift b/docmostlyTests/Editor/NativeEditorLinkFidelityTests.swift new file mode 100644 index 0000000..12f1ac3 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorLinkFidelityTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorLinkFidelityTests { + @Test func preservesDocmostRelativeInternalLinksFromProseMirror() throws { + let document = try NativeEditorDocument(proseMirrorJSONData: Data(""" + { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Spec", + "marks": [ + { + "type": "link", + "attrs": { + "href": "/api/files/file-1/Spec.pdf", + "internal": true + } + } + ] + } + ] + } + ] + } + """.utf8)) + + let reencodedText = try #require(document.proseMirrorDocument.content.first?.content?.first) + #expect(reencodedText.marks == [ + ProseMirrorMark( + type: "link", + attrs: [ + "href": .string("/api/files/file-1/Spec.pdf"), + "internal": .bool(true) + ] + ) + ]) + + #expect(NativeEditorMarkdownParser.markdown(from: document.blocks) == "[Spec](/api/files/file-1/Spec.pdf)") + } +} diff --git a/docmostlyTests/Editor/NativeEditorListFidelityTests.swift b/docmostlyTests/Editor/NativeEditorListFidelityTests.swift new file mode 100644 index 0000000..660dc47 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorListFidelityTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing +@testable import docmostly + +struct NativeEditorListFidelityTests { + @Test func editingListItemPreservesAdditionalDocmostListItemContent() throws { + let original = try JSONDecoder().decode( + ProseMirrorDocument.self, + from: Data(""" + { + "type": "doc", + "content": [ + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Launch checklist" }] + }, + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Keep rollout notes attached" }] + } + ] + } + ] + } + ] + } + """.utf8) + ) + var document = NativeEditorDocument(proseMirrorDocument: original) + + #expect(document.blocks.count == 1) + #expect(document.proseMirrorDocument == original) + + document.blocks[0].text = AttributedString("Release checklist") + + let listItem = try #require(document.proseMirrorDocument.content.first?.content?.first) + let paragraphs = try #require(listItem.content) + #expect(paragraphs.count == 2) + #expect(paragraphs[0].content?.first?.text == "Release checklist") + #expect(paragraphs[1].content?.first?.text == "Keep rollout notes attached") + } + + @Test func editingListItemPreservesAdditionalContentAndNestedLists() throws { + let original = try JSONDecoder().decode( + ProseMirrorDocument.self, + from: Data(""" + { + "type": "doc", + "content": [ + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Parent" }] + }, + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Context" }] + }, + { + "type": "taskList", + "content": [ + { + "type": "taskItem", + "attrs": { "checked": true }, + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Child task" }] + } + ] + } + ] + } + ] + } + ] + } + ] + } + """.utf8) + ) + var document = NativeEditorDocument(proseMirrorDocument: original) + + #expect(document.blocks.count == 2) + document.blocks[0].text = AttributedString("Updated parent") + document.blocks[1].text = AttributedString("Updated child task") + + let listItem = try #require(document.proseMirrorDocument.content.first?.content?.first) + let content = try #require(listItem.content) + #expect(content.count == 3) + #expect(content[0].content?.first?.text == "Updated parent") + #expect(content[1].content?.first?.text == "Context") + #expect(content[2].type == "taskList") + #expect(content[2].content?.first?.content?.first?.content?.first?.text == "Updated child task") + } +} diff --git a/docmostlyTests/Editor/NativeEditorListMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorListMarkdownTests.swift new file mode 100644 index 0000000..7675963 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorListMarkdownTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorListMarkdownTests { + @Test func bulletListMarkdownExportPrefixesContinuationLines() throws { + let block = NativeEditorBlock( + kind: .bulletListItem, + text: AttributedString("First line\nSecond line"), + alignment: .left + ) + + let markdown = NativeEditorMarkdownParser.markdown(from: [block]) + + #expect(markdown == """ + - First line + Second line + """) + + let importedBlock = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + #expect(importedBlock.kind == .bulletListItem) + #expect(String(importedBlock.text.characters) == "First line\nSecond line") + } + + @Test func orderedListMarkdownExportPrefixesContinuationLines() throws { + let block = NativeEditorBlock( + kind: .orderedListItem(ordinal: 12), + text: AttributedString("First line\nSecond line"), + alignment: .left + ) + + let markdown = NativeEditorMarkdownParser.markdown(from: [block]) + + #expect(markdown == """ + 12. First line + Second line + """) + + let importedBlock = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + #expect(importedBlock.kind == .orderedListItem(ordinal: 12)) + #expect(String(importedBlock.text.characters) == "First line\nSecond line") + } + + @Test func taskListMarkdownExportPrefixesContinuationLines() throws { + let block = NativeEditorBlock( + kind: .taskListItem(isChecked: false), + text: AttributedString("First line\nSecond line"), + alignment: .left + ) + + let markdown = NativeEditorMarkdownParser.markdown(from: [block]) + + #expect(markdown == """ + - [ ] First line + Second line + """) + + let importedBlock = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + #expect(importedBlock.kind == .taskListItem(isChecked: false)) + #expect(String(importedBlock.text.characters) == "First line\nSecond line") + } + + @Test func markdownImportSupportsPlusListMarkers() throws { + let blocks = NativeEditorMarkdownParser.blocks(from: """ + + Release notes + + [ ] QA pass + + [x] Ship build + """) + + try #require(blocks.count == 3) + #expect(blocks[0].kind == .bulletListItem) + #expect(String(blocks[0].text.characters) == "Release notes") + #expect(blocks[1].kind == .taskListItem(isChecked: false)) + #expect(String(blocks[1].text.characters) == "QA pass") + #expect(blocks[2].kind == .taskListItem(isChecked: true)) + #expect(String(blocks[2].text.characters) == "Ship build") + } + + @Test func markdownInputRuleSupportsPlusListMarkers() throws { + let bulletRule = try #require(NativeEditorMarkdownParser.inputRule(from: "+ Release notes")) + let uncheckedTaskRule = try #require(NativeEditorMarkdownParser.inputRule(from: "+ [ ] QA pass")) + let checkedTaskRule = try #require(NativeEditorMarkdownParser.inputRule(from: "+ [x] Ship build")) + + #expect(bulletRule.kind == .bulletListItem) + #expect(bulletRule.text == "Release notes") + #expect(uncheckedTaskRule.kind == .taskListItem(isChecked: false)) + #expect(uncheckedTaskRule.text == "QA pass") + #expect(checkedTaskRule.kind == .taskListItem(isChecked: true)) + #expect(checkedTaskRule.text == "Ship build") + } +} diff --git a/docmostlyTests/Editor/NativeEditorMarkdownBalancedLinkTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownBalancedLinkTests.swift new file mode 100644 index 0000000..b953c3a --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorMarkdownBalancedLinkTests.swift @@ -0,0 +1,25 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorMarkdownBalancedLinkTests { + @Test func inlineMarkdownLinksImportDestinationsWithBalancedParentheses() throws { + let source = "https://example.com/releases/Launch_(June)" + let block = try #require(NativeEditorMarkdownParser.blocks( + from: #"Stage Ship [Launch notes](\#(source))"# + ).first) + + #expect(block.kind == .paragraph) + let statusRun = try #require(block.text.runs.first { run in + run[NativeEditorStatusAttribute.self]?.text == "Ship" + }) + #expect(statusRun[NativeEditorStatusAttribute.self]?.color == "green") + + let linkRun = try #require(block.text.runs.first { run in + run.link?.absoluteString == source + }) + #expect(String(block.text[linkRun.range].characters) == "Launch notes") + #expect(String(block.text.characters) == "Stage Ship Launch notes") + } +} diff --git a/docmostlyTests/Editor/NativeEditorMarkdownFrontMatterImportTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownFrontMatterImportTests.swift new file mode 100644 index 0000000..c5df60e --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorMarkdownFrontMatterImportTests.swift @@ -0,0 +1,27 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorFrontMatterImportTests { + @Test func markdownImportDropsLeadingYAMLFrontMatter() throws { + let blocks = NativeEditorMarkdownParser.blocks(from: """ + --- + title: Launch plan + tags: + - rollout + --- + + # Overview + Ready to ship + """) + + #expect(blocks.count == 2) + let heading = try #require(blocks.first) + let paragraph = try #require(blocks.last) + #expect(heading.kind == .heading(level: 1)) + #expect(String(heading.text.characters) == "Overview") + #expect(paragraph.kind == .paragraph) + #expect(String(paragraph.text.characters) == "Ready to ship") + } +} diff --git a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift index 6f68854..1f98019 100644 --- a/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorMarkdownImportTests.swift @@ -4,6 +4,23 @@ import Testing @MainActor struct NativeEditorMarkdownImportTests { + @Test func markdownSingleNewlinesImportAsHardBreaksInsideParagraph() throws { + let blocks = NativeEditorMarkdownParser.blocks(from: """ + Line one + Line two + """) + + #expect(blocks.count == 1) + let block = try #require(blocks.first) + #expect(block.kind == .paragraph) + #expect(String(block.text.characters) == "Line one\nLine two") + + let node = NativeEditorDocument.node(from: block) + #expect(node.content?.map(\.type) == ["text", "hardBreak", "text"]) + #expect(node.content?.first?.text == "Line one") + #expect(node.content?.last?.text == "Line two") + } + @Test func markdownLinksImportAsNativeMediaAndAttachmentBlocks() throws { let blocks = NativeEditorMarkdownParser.blocks(from: """ [Launch demo.mp4](/files/demo.mp4) @@ -47,10 +64,397 @@ struct NativeEditorMarkdownImportTests { #expect(blocks[3].rawNode?.type == "attachment") } + @Test func docmostAttachmentLinksImportWithAttachmentIDs() throws { + let blocks = NativeEditorMarkdownParser.blocks(from: """ + ![Hero](/api/files/image-1/Hero.png) + [Launch demo.mp4](/api/files/video-1/Launch%20demo.mp4) + [Spec.pdf](/api/files/pdf-1/Spec.pdf) + [Archive.zip](/api/files/file-1/Archive.zip) + """) + + #expect(blocks.count == 4) + + guard case .image(let image) = blocks[0].kind else { + Issue.record("Expected Docmost image link to import as an image block.") + return + } + #expect(image.attachmentID == "image-1") + #expect(blocks[0].rawNode?.attrs?["attachmentId"] == .string("image-1")) + + guard case .video(let video) = blocks[1].kind else { + Issue.record("Expected Docmost video link to import as a video block.") + return + } + #expect(video.attachmentID == "video-1") + #expect(blocks[1].rawNode?.attrs?["attachmentId"] == .string("video-1")) + + guard case .pdf(let pdf) = blocks[2].kind else { + Issue.record("Expected Docmost PDF link to import as a PDF block.") + return + } + #expect(pdf.attachmentID == "pdf-1") + #expect(blocks[2].rawNode?.attrs?["attachmentId"] == .string("pdf-1")) + + guard case .attachment(let attachment) = blocks[3].kind else { + Issue.record("Expected Docmost file link to import as an attachment block.") + return + } + #expect(attachment.attachmentID == "file-1") + #expect(blocks[3].rawNode?.attrs?["attachmentId"] == .string("file-1")) + } + + @Test func oneSegmentFilesRoutesDoNotImportAttachmentIDs() throws { + let blocks = NativeEditorMarkdownParser.blocks(from: """ + ![Manual](/api/files/manual.png) + [Spec.pdf](/api/files/manual.pdf) + [Archive.zip](/api/files/archive.zip) + """) + + try #require(blocks.count == 3) + + guard case .image(let image) = blocks[0].kind else { + Issue.record("Expected image Markdown to import as an image block.") + return + } + #expect(image.attachmentID == nil) + #expect(blocks[0].rawNode?.attrs?["attachmentId"] == nil) + + guard case .pdf(let pdf) = blocks[1].kind else { + Issue.record("Expected PDF Markdown to import as a PDF block.") + return + } + #expect(pdf.attachmentID == nil) + #expect(blocks[1].rawNode?.attrs?["attachmentId"] == nil) + + guard case .attachment(let attachment) = blocks[2].kind else { + Issue.record("Expected file Markdown to import as an attachment block.") + return + } + #expect(attachment.attachmentID == nil) + #expect(blocks[2].rawNode?.attrs?["attachmentId"] == nil) + } + + @Test func markdownImageTitleImportsAsNativeMediaTitle() throws { + let block = try #require(NativeEditorMarkdownParser.blocks( + from: #"![Architecture](/files/image.png "System diagram")"# + ).first) + + guard case .image(let image) = block.kind else { + Issue.record("Expected Markdown image to import as a native image block.") + return + } + + #expect(image.source == "/files/image.png") + #expect(image.alternativeText == "Architecture") + #expect(image.title == "System diagram") + #expect(block.rawNode?.attrs?["title"] == .string("System diagram")) + } + + @Test func docmostPageBreakHTMLImportsAsNativePageBreakBlock() throws { + let block = try #require(NativeEditorMarkdownParser.blocks( + from: #"
"# + ).first) + + #expect(block.kind == .pageBreak) + #expect(block.rawNode?.type == "pageBreak") + } + + @Test func legacyPageBreakHTMLImportsAsNativePageBreakBlock() throws { + let block = try #require(NativeEditorMarkdownParser.blocks( + from: #"
"# + ).first) + + #expect(block.kind == .pageBreak) + #expect(block.rawNode?.type == "pageBreak") + } + + @Test func docmostColumnsHTMLImportsAsNativeColumnsBlock() throws { + let markdown = """ +
+
+ Navigation +
+
+ Main content +
+
+ """ + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + guard case .columns(let columns) = block.kind else { + Issue.record("Expected Docmost columns HTML to import as a native columns block.") + return + } + + #expect(columns.layout == "two_left_sidebar") + #expect(columns.widthMode == "wide") + #expect(columns.columnCount == 2) + #expect(columns.columnTexts == ["Navigation", "Main content"]) + #expect(block.rawNode?.type == "columns") + #expect(block.rawNode?.attrs?["layout"] == .string("two_left_sidebar")) + #expect(block.rawNode?.attrs?["widthMode"] == .string("wide")) + #expect(block.rawNode?.content?.map(\.type) == ["column", "column"]) + #expect(block.rawNode?.content?[0].attrs?["width"] == .double(0.6)) + #expect(block.rawNode?.content?[1].attrs?["width"] == .double(1.4)) + #expect(NativeEditorMarkdownParser.markdown(from: [block]) == markdown) + } + + @Test func docmostDiagramHTMLImportsAsNativeDiagramBlocks() throws { + let drawioSource = "/api/files/drawio-1/diagram.drawio.svg" + let excalidrawSource = "/api/files/excalidraw-1/sketch.png" + let markdown = [ + docmostDiagramHTML( + type: "drawio", + source: drawioSource, + title: "System map", + alternativeText: "System diagram", + attachmentID: "drawio-1", + size: "2048", + width: "640", + height: "360", + aspectRatio: "1.7777778", + alignment: "center" + ), + docmostDiagramHTML( + type: "excalidraw", + source: excalidrawSource, + title: "Sketch", + alternativeText: "Whiteboard sketch", + attachmentID: "excalidraw-1", + width: "75%", + alignment: "right" + ) + ].joined(separator: "\n") + let blocks = NativeEditorMarkdownParser.blocks(from: markdown) + + try #require(blocks.count == 2) + guard case .drawio(let drawio) = blocks[0].kind else { + Issue.record("Expected Docmost draw.io HTML to import as a native draw.io block.") + return + } + guard case .excalidraw(let excalidraw) = blocks[1].kind else { + Issue.record("Expected Docmost Excalidraw HTML to import as a native Excalidraw block.") + return + } + + #expect(drawio.source == drawioSource) + #expect(drawio.title == "System map") + #expect(drawio.alternativeText == "System diagram") + #expect(drawio.attachmentID == "drawio-1") + #expect(drawio.sizeInBytes == 2_048) + #expect(drawio.width == "640") + #expect(drawio.height == "360") + #expect(drawio.aspectRatio == "1.7777778") + #expect(drawio.alignment == "center") + #expect(blocks[0].rawNode?.type == "drawio") + #expect(blocks[0].rawNode?.attrs?["src"] == .string(drawioSource)) + #expect(blocks[0].rawNode?.attrs?["width"] == .int(640)) + #expect(blocks[0].rawNode?.attrs?["attachmentId"] == .string("drawio-1")) + #expect(excalidraw.source == excalidrawSource) + #expect(excalidraw.width == "75%") + #expect(blocks[1].rawNode?.type == "excalidraw") + #expect(blocks[1].rawNode?.attrs?["width"] == .string("75%")) + #expect(NativeEditorMarkdownParser.markdown(from: blocks) == markdown) + } + + @Test func docmostStructuralHTMLImportsAsNativeStructuralBlocks() throws { + let markdown = """ +
+
+ Reusable launch checklist +
+
+
+ """ + let blocks = NativeEditorMarkdownParser.blocks(from: markdown) + + try #require(blocks.count == 4) + #expect(blocks[0].kind == .subpages) + #expect(blocks[0].rawNode?.type == "subpages") + + guard case .transclusionSource(let source) = blocks[1].kind else { + Issue.record("Expected Docmost transclusion source HTML to import as a native synced block.") + return + } + #expect(source.identifier == "sync-1") + #expect(source.previewText == "Reusable launch checklist") + #expect(blocks[1].rawNode?.type == "transclusionSource") + #expect(blocks[1].rawNode?.attrs?["id"] == .string("sync-1")) + + guard case .transclusionReference(let reference) = blocks[2].kind else { + Issue.record("Expected Docmost transclusion reference HTML to import as a native synced block reference.") + return + } + #expect(reference.sourcePageID == "page-1") + #expect(reference.transclusionID == "sync-1") + #expect(blocks[2].rawNode?.type == "transclusionReference") + #expect(blocks[2].rawNode?.attrs?["sourcePageId"] == .string("page-1")) + #expect(blocks[2].rawNode?.attrs?["transclusionId"] == .string("sync-1")) + + guard case .base(let base) = blocks[3].kind else { + Issue.record("Expected Docmost base embed HTML to import as a native base block.") + return + } + #expect(base.pageID == "base-page-1") + #expect(base.pendingKey == nil) + #expect(blocks[3].rawNode?.type == "base") + #expect(blocks[3].rawNode?.attrs?["pageId"] == .string("base-page-1")) + #expect(NativeEditorMarkdownParser.markdown(from: blocks) == markdown) + } + + @Test func singleLineDocmostDiagramHTMLImportsAsNativeDiagramBlock() throws { + let source = "/api/files/drawio-1/diagram.drawio.svg" + let markdown = singleLineDocmostDiagramHTML( + type: "drawio", + source: source, + title: "System map", + alternativeText: "System diagram", + attachmentID: "drawio-1", + width: "640" + ) + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + guard case .drawio(let drawio) = block.kind else { + Issue.record("Expected compact Docmost draw.io HTML to import as a native draw.io block.") + return + } + + #expect(drawio.source == source) + #expect(drawio.title == "System map") + #expect(drawio.alternativeText == "System diagram") + #expect(drawio.attachmentID == "drawio-1") + #expect(drawio.width == "640") + #expect(block.rawNode?.type == "drawio") + #expect(block.rawNode?.attrs?["src"] == .string(source)) + } + + @Test func longerHTMLTagNamesDoNotImportAsDocmostMediaBlocks() throws { + let imageLikeBlock = try #require( + NativeEditorMarkdownParser.blocks(from: #""#).first + ) + let diagramLikeBlock = try #require( + NativeEditorMarkdownParser.blocks( + from: #""# + ).first + ) + + #expect(imageLikeBlock.kind == .paragraph) + #expect(String(imageLikeBlock.text.characters) == #""#) + #expect(diagramLikeBlock.kind == .paragraph) + #expect(String(diagramLikeBlock.text.characters).contains(" String { + let openingTag = htmlTag("div", attributes: [ + ("data-type", type), + ("data-src", source), + ("data-title", title), + ("data-alt", alternativeText), + ("data-width", width), + ("data-height", height), + ("data-size", size), + ("data-aspect-ratio", aspectRatio), + ("data-align", alignment), + ("data-attachment-id", attachmentID) + ]) + let imageTag = htmlTag("img", attributes: [ + ("src", source), + ("alt", alternativeText), + ("width", width) + ]) + + return """ + \(openingTag) + \(imageTag) + + """ + } + + private func singleLineDocmostDiagramHTML( + type: String, + source: String, + title: String, + alternativeText: String, + attachmentID: String, + width: String? = nil + ) -> String { + let openingTag = htmlTag("div", attributes: [ + ("data-type", type), + ("data-src", source), + ("data-title", title), + ("data-alt", alternativeText), + ("data-width", width), + ("data-attachment-id", attachmentID) + ]) + let imageTag = htmlTag("img", attributes: [ + ("src", source), + ("alt", alternativeText), + ("width", width) + ]) + + return "\(openingTag)\(imageTag)" + } + + private func htmlTag(_ name: String, attributes: [(String, String?)]) -> String { + let attributeText = attributes.compactMap { key, value -> String? in + value.map { #"\#(key)="\#($0)""# } + }.joined(separator: " ") + return "<\(name) \(attributeText)>" + } } diff --git a/docmostlyTests/Editor/NativeEditorMarkdownLinkTitleTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownLinkTitleTests.swift new file mode 100644 index 0000000..52f729a --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorMarkdownLinkTitleTests.swift @@ -0,0 +1,34 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorMarkdownLinkTitleTests { + @Test func imageTitleImportSupportsCommonMarkdownTitleDelimiters() throws { + let markdownCases = [ + #"![Architecture](/files/image.png 'System diagram')"#, + #"![Architecture](/files/image.png (System diagram))"#, + #"![Architecture]( "System diagram")"# + ] + + for markdown in markdownCases { + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + guard case .image(let image) = block.kind else { + Issue.record("Expected Markdown image to import as a native image block.") + continue + } + + #expect(image.source == "/files/image.png") + #expect(image.title == "System diagram") + #expect(block.rawNode?.attrs?["title"] == .string("System diagram")) + } + } + + @Test func escapedWhitespaceBeforeDelimiterDoesNotStartMarkdownLinkTitle() { + let destination = #"/files/my\ "diagram.png""# + + #expect(NativeEditorMarkdownParser.markdownLinkSource(from: destination) == destination) + #expect(NativeEditorMarkdownParser.markdownLinkTitle(from: destination) == nil) + } +} diff --git a/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift new file mode 100644 index 0000000..7fbaae1 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorMathMarkdownTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorMathMarkdownTests { + @Test func nativeMathBlockExportsAsDocmostMathFenceMarkdown() { + let block = NativeEditorBlock( + kind: .mathBlock(NativeEditorMathBlock(text: "E = mc^2")), + text: AttributedString("E = mc^2"), + alignment: .left + ) + + #expect(NativeEditorMarkdownParser.markdown(from: [block]) == """ + $$ + E = mc^2 + $$ + """) + } + + @Test func markdownImportSupportsSingleLineMathBlockFence() throws { + let block = try #require(NativeEditorMarkdownParser.blocks( + from: "$$E = mc^2$$" + ).first) + + guard case .mathBlock(let math) = block.kind else { + Issue.record("Expected standalone double-dollar math to import as a native math block.") + return + } + + #expect(math.text == "E = mc^2") + #expect(block.rawNode?.type == "mathBlock") + #expect(block.rawNode?.attrs?["text"] == .string("E = mc^2")) + #expect(NativeEditorMarkdownParser.markdown(from: [block]) == """ + $$ + E = mc^2 + $$ + """) + } + + @Test func markdownImportKeepsCurrencyDollarAmountsAsPlainText() throws { + let block = try #require(NativeEditorMarkdownParser.blocks( + from: "Budget is $5 and $6 tomorrow" + ).first) + + #expect(String(block.text.characters) == "Budget is $5 and $6 tomorrow") + #expect(block.text.runs.contains { run in + run[NativeEditorMathInlineAttribute.self] != nil + } == false) + + let inlineNodes = try #require(NativeEditorDocument.node(from: block).content) + #expect(inlineNodes.contains { node in node.type == "mathInline" } == false) + #expect(inlineNodes.compactMap(\.text).joined() == "Budget is $5 and $6 tomorrow") + } + + @Test func markdownImportKeepsMathDelimitersInsideCodeSpansAsCodeText() throws { + let block = try #require(NativeEditorMarkdownParser.blocks( + from: "Use `$value$` and $$total$$" + ).first) + + let codeRun = try #require(block.text.runs.first { run in + String(block.text[run.range].characters) == "$value$" + }) + #expect(codeRun.inlinePresentationIntent?.contains(.code) == true) + #expect(codeRun[NativeEditorMathInlineAttribute.self] == nil) + + let mathRun = try #require(block.text.runs.first { run in + run[NativeEditorMathInlineAttribute.self]?.text == "total" + }) + #expect(String(block.text[mathRun.range].characters) == "total") + } +} diff --git a/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift b/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift new file mode 100644 index 0000000..2c1952e --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorMediaHTMLFidelityTests.swift @@ -0,0 +1,451 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorMediaHTMLFidelityTests { + @Test func exportsDocmostMediaAndEmbedHTMLShapes() { + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: nativeBlocks()) + viewModel.resetEditingHistory() + + #expect(viewModel.markdownForDocument() == docmostHTMLMarkdown()) + } + + @Test func importsDocmostMediaAndEmbedHTMLAsTypedNativeBlocks() throws { + let markdown = docmostHTMLMarkdown() + let blocks = NativeEditorMarkdownParser.blocks(from: markdown) + + try #require(blocks.count == 6) + verifyImage(blocks[0]) + verifyVideo(blocks[1]) + verifyAudio(blocks[2]) + verifyPDF(blocks[3]) + verifyAttachment(blocks[4]) + verifyEmbed(blocks[5]) + #expect(NativeEditorMarkdownParser.markdown(from: blocks) == markdown) + } + + @Test func importsCompactDocmostMediaAndEmbedHTMLAsTypedNativeBlocks() throws { + let blocks = NativeEditorMarkdownParser.blocks(from: compactDocmostHTMLMarkdown()) + + try #require(blocks.count == 5) + verifyVideo(blocks[0]) + verifyAudio(blocks[1]) + verifyPDF(blocks[2]) + verifyAttachment(blocks[3]) + verifyEmbed(blocks[4]) + } + + @Test func importsDocmostEmbedHTMLWithNestedProviderDivBeforeLink() throws { + let markdown = """ +
+
+ Provider metadata +
+ https://www.figma.com/file/demo +
+ """ + let blocks = NativeEditorMarkdownParser.blocks(from: markdown) + + try #require(blocks.count == 1) + verifyEmbed(blocks[0]) + #expect(NativeEditorMarkdownParser.markdown(from: blocks) == embedHTML()) + } + + @Test func importsStandaloneIframeHTMLAsIframeEmbedBlock() throws { + let markdown = #""# + let blocks = NativeEditorMarkdownParser.blocks(from: markdown) + + try #require(blocks.count == 1) + guard case .embed(let embed) = blocks[0].kind else { + Issue.record("Expected standalone iframe HTML to import as an iframe embed block.") + return + } + + #expect(embed.source == "https://example.com/embed/demo") + #expect(embed.provider == "iframe") + #expect(NativeEditorDocument.node(from: blocks[0]).type == "embed") + #expect(NativeEditorMarkdownParser.markdown(from: blocks) == """ + [https://example.com/embed/demo](https://example.com/embed/demo) + """) + } + + private func nativeBlocks() -> [NativeEditorBlock] { + [ + NativeEditorBlock(kind: .image(imageBlock()), text: AttributedString("Hero"), alignment: .left), + NativeEditorBlock(kind: .video(videoBlock()), text: AttributedString("Launch demo"), alignment: .left), + NativeEditorBlock(kind: .audio(audioBlock()), text: AttributedString("Audio"), alignment: .left), + NativeEditorBlock(kind: .pdf(pdfBlock()), text: AttributedString("Spec.pdf"), alignment: .left), + NativeEditorBlock( + kind: .attachment(attachmentBlock()), + text: AttributedString("Archive.zip"), + alignment: .left + ), + NativeEditorBlock(kind: .embed(embedBlock()), text: AttributedString("Figma"), alignment: .left) + ] + } + + private func imageBlock() -> NativeEditorMediaBlock { + NativeEditorMediaBlock( + source: "/api/files/image-1/Hero.png", + alternativeText: "Hero", + title: "Launch hero", + attachmentID: "image-1", + sizeInBytes: 2_048, + width: "640", + height: "360", + aspectRatio: "1.7777778", + alignment: "center" + ) + } + + private func videoBlock() -> NativeEditorMediaBlock { + NativeEditorMediaBlock( + source: "/api/files/video-1/Demo.mp4", + alternativeText: "Launch demo", + title: nil, + attachmentID: "video-1", + sizeInBytes: 8_192, + width: "75%", + height: "360", + aspectRatio: "1.7777778", + alignment: "right" + ) + } + + private func audioBlock() -> NativeEditorMediaBlock { + NativeEditorMediaBlock( + source: "/api/files/audio-1/Briefing.m4a", + alternativeText: nil, + title: nil, + attachmentID: "audio-1", + sizeInBytes: 4_096, + width: nil, + height: nil, + aspectRatio: nil, + alignment: nil + ) + } + + private func pdfBlock() -> NativeEditorPDFBlock { + NativeEditorPDFBlock( + source: "/api/files/pdf-1/Spec.pdf", + name: "Spec.pdf", + attachmentID: "pdf-1", + sizeInBytes: 16_384, + width: "800", + height: "600" + ) + } + + private func attachmentBlock() -> NativeEditorAttachmentBlock { + NativeEditorAttachmentBlock( + url: "/api/files/file-1/Archive.zip", + name: "Archive.zip", + mimeType: "application/zip", + sizeInBytes: 1_024, + attachmentID: "file-1" + ) + } + + private func embedBlock() -> NativeEditorEmbedBlock { + NativeEditorEmbedBlock( + source: "https://www.figma.com/file/demo", + provider: "Figma", + alignment: "center", + width: "800", + height: "600" + ) + } + + private func docmostHTMLMarkdown() -> String { + [ + imageHTML(), + videoHTML(), + audioHTML(), + pdfHTML(), + attachmentHTML(), + embedHTML() + ].joined(separator: "\n") + } + + private func compactDocmostHTMLMarkdown() -> String { + [ + compactVideoHTML(), + compactAudioHTML(), + compactPDFHTML(), + compactAttachmentHTML(), + compactEmbedHTML() + ].joined(separator: "\n") + } + + private func imageHTML() -> String { + htmlTag("img", attributes: [ + ("src", "/api/files/image-1/Hero.png"), + ("alt", "Hero"), + ("title", "Launch hero"), + ("width", "640"), + ("height", "360"), + ("data-align", "center"), + ("data-attachment-id", "image-1"), + ("data-size", "2048"), + ("data-aspect-ratio", "1.7777778") + ]) + } + + private func videoHTML() -> String { + """ + \(htmlTag("video", attributes: videoAttributes())) + + + """ + } + + private func compactVideoHTML() -> String { + [ + htmlTag("video", attributes: compactVideoAttributes()), + #""# + ].joined() + } + + private func videoAttributes() -> [(String, String?)] { + [ + ("controls", "true"), + ("src", "/api/files/video-1/Demo.mp4"), + ("aria-label", "Launch demo"), + ("data-attachment-id", "video-1"), + ("width", "75%"), + ("height", "360"), + ("data-size", "8192"), + ("data-align", "right"), + ("data-aspect-ratio", "1.7777778") + ] + } + + private func compactVideoAttributes() -> [(String, String?)] { + videoAttributes().filter { key, _ in key != "src" } + } + + private func audioHTML() -> String { + """ + \(htmlTag("audio", attributes: audioAttributes())) + + + """ + } + + private func compactAudioHTML() -> String { + [ + htmlTag("audio", attributes: compactAudioAttributes()), + #""# + ].joined() + } + + private func audioAttributes() -> [(String, String?)] { + [ + ("controls", "true"), + ("preload", "metadata"), + ("src", "/api/files/audio-1/Briefing.m4a"), + ("data-attachment-id", "audio-1"), + ("data-size", "4096") + ] + } + + private func compactAudioAttributes() -> [(String, String?)] { + audioAttributes().filter { key, _ in key != "src" } + } + + private func pdfHTML() -> String { + """ + \(htmlTag("div", attributes: pdfContainerAttributes())) + + + """ + } + + private func compactPDFHTML() -> String { + [ + htmlTag("div", attributes: compactPDFContainerAttributes()), + #""# + ].joined() + } + + private func pdfContainerAttributes() -> [(String, String?)] { + [ + ("data-type", "pdf"), + ("src", "/api/files/pdf-1/Spec.pdf"), + ("data-name", "Spec.pdf"), + ("data-attachment-id", "pdf-1"), + ("data-size", "16384"), + ("width", "800"), + ("height", "600") + ] + } + + private func compactPDFContainerAttributes() -> [(String, String?)] { + pdfContainerAttributes().filter { key, _ in key != "src" } + } + + private func attachmentHTML() -> String { + """ + \(htmlTag("div", attributes: attachmentContainerAttributes())) + Archive.zip + + """ + } + + private func compactAttachmentHTML() -> String { + [ + htmlTag("div", attributes: compactAttachmentContainerAttributes()), + #""#, + "Archive.zip" + ].joined() + } + + private func attachmentContainerAttributes() -> [(String, String?)] { + [ + ("data-type", "attachment"), + ("data-attachment-url", "/api/files/file-1/Archive.zip"), + ("data-attachment-name", "Archive.zip"), + ("data-attachment-mime", "application/zip"), + ("data-attachment-size", "1024"), + ("data-attachment-id", "file-1") + ] + } + + private func compactAttachmentContainerAttributes() -> [(String, String?)] { + attachmentContainerAttributes().filter { key, _ in key != "data-attachment-url" } + } + + private func embedHTML() -> String { + """ + \(htmlTag("div", attributes: embedContainerAttributes())) + https://www.figma.com/file/demo + + """ + } + + private func compactEmbedHTML() -> String { + [ + htmlTag("div", attributes: compactEmbedContainerAttributes()), + #""#, + "https://www.figma.com/file/demo" + ].joined() + } + + private func embedContainerAttributes() -> [(String, String?)] { + [ + ("data-type", "embed"), + ("data-src", "https://www.figma.com/file/demo"), + ("data-provider", "Figma"), + ("data-align", "center"), + ("data-width", "800"), + ("data-height", "600") + ] + } + + private func compactEmbedContainerAttributes() -> [(String, String?)] { + embedContainerAttributes().filter { key, _ in key != "data-src" } + } + + private func verifyImage(_ block: NativeEditorBlock) { + guard case .image(let image) = block.kind else { + Issue.record("Expected Docmost image HTML to import as a native image block.") + return + } + + #expect(image.source == "/api/files/image-1/Hero.png") + #expect(image.alternativeText == "Hero") + #expect(image.title == "Launch hero") + #expect(image.attachmentID == "image-1") + #expect(image.sizeInBytes == 2_048) + #expect(image.width == "640") + #expect(image.height == "360") + #expect(image.aspectRatio == "1.7777778") + #expect(image.alignment == "center") + #expect(block.rawNode?.type == "image") + #expect(block.rawNode?.attrs?["title"] == .string("Launch hero")) + #expect(block.rawNode?.attrs?["width"] == .int(640)) + } + + private func verifyVideo(_ block: NativeEditorBlock) { + guard case .video(let video) = block.kind else { + Issue.record("Expected Docmost video HTML to import as a native video block.") + return + } + + #expect(video.source == "/api/files/video-1/Demo.mp4") + #expect(video.alternativeText == "Launch demo") + #expect(video.attachmentID == "video-1") + #expect(video.sizeInBytes == 8_192) + #expect(video.width == "75%") + #expect(video.height == "360") + #expect(video.aspectRatio == "1.7777778") + #expect(video.alignment == "right") + #expect(block.rawNode?.type == "video") + #expect(block.rawNode?.attrs?["width"] == .string("75%")) + } + + private func verifyAudio(_ block: NativeEditorBlock) { + guard case .audio(let audio) = block.kind else { + Issue.record("Expected Docmost audio HTML to import as a native audio block.") + return + } + + #expect(audio.source == "/api/files/audio-1/Briefing.m4a") + #expect(audio.attachmentID == "audio-1") + #expect(audio.sizeInBytes == 4_096) + #expect(block.rawNode?.type == "audio") + } + + private func verifyPDF(_ block: NativeEditorBlock) { + guard case .pdf(let pdf) = block.kind else { + Issue.record("Expected Docmost PDF HTML to import as a native PDF block.") + return + } + + #expect(pdf.source == "/api/files/pdf-1/Spec.pdf") + #expect(pdf.name == "Spec.pdf") + #expect(pdf.attachmentID == "pdf-1") + #expect(pdf.sizeInBytes == 16_384) + #expect(pdf.width == "800") + #expect(pdf.height == "600") + #expect(block.rawNode?.type == "pdf") + } + + private func verifyAttachment(_ block: NativeEditorBlock) { + guard case .attachment(let attachment) = block.kind else { + Issue.record("Expected Docmost attachment HTML to import as a native attachment block.") + return + } + + #expect(attachment.url == "/api/files/file-1/Archive.zip") + #expect(attachment.name == "Archive.zip") + #expect(attachment.mimeType == "application/zip") + #expect(attachment.sizeInBytes == 1_024) + #expect(attachment.attachmentID == "file-1") + #expect(block.rawNode?.type == "attachment") + } + + private func verifyEmbed(_ block: NativeEditorBlock) { + guard case .embed(let embed) = block.kind else { + Issue.record("Expected Docmost embed HTML to import as a native embed block.") + return + } + + #expect(embed.source == "https://www.figma.com/file/demo") + #expect(embed.provider == "Figma") + #expect(embed.alignment == "center") + #expect(embed.width == "800") + #expect(embed.height == "600") + #expect(block.rawNode?.type == "embed") + } + + private func htmlTag(_ name: String, attributes: [(String, String?)]) -> String { + let attributeText = attributes.compactMap { key, value -> String? in + value.map { #"\#(key)="\#($0)""# } + }.joined(separator: " ") + return "<\(name) \(attributeText)>" + } +} diff --git a/docmostlyTests/Editor/NativeEditorMediaMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorMediaMarkdownExportTests.swift new file mode 100644 index 0000000..533186d --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorMediaMarkdownExportTests.swift @@ -0,0 +1,115 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorMediaMarkdownExportTests { + @Test func markdownExportUsesFilenameLabelsForUnlabeledFileBlocks() { + let blocks = [ + NativeEditorBlock( + kind: .video(NativeEditorMediaBlock( + source: "/api/files/video-1/Launch%20demo.mp4", + alternativeText: nil, + title: nil, + attachmentID: nil, + sizeInBytes: nil, + width: nil, + height: nil, + aspectRatio: nil, + alignment: nil + )), + text: AttributedString(""), + alignment: .left + ), + NativeEditorBlock( + kind: .audio(NativeEditorMediaBlock( + source: #"folder name\Briefing.m4a"#, + alternativeText: nil, + title: nil, + attachmentID: nil, + sizeInBytes: nil, + width: nil, + height: nil, + aspectRatio: nil, + alignment: nil + )), + text: AttributedString(""), + alignment: .left + ), + NativeEditorBlock( + kind: .pdf(NativeEditorPDFBlock( + source: "/api/files/pdf-1/Spec.pdf?download=1", + name: nil, + attachmentID: nil, + sizeInBytes: nil, + width: nil, + height: nil + )), + text: AttributedString(""), + alignment: .left + ), + NativeEditorBlock( + kind: .attachment(NativeEditorAttachmentBlock( + url: "/api/files/file-1/Archive.zip#download", + name: nil, + mimeType: nil, + sizeInBytes: nil, + attachmentID: nil + )), + text: AttributedString(""), + alignment: .left + ) + ] + + #expect(NativeEditorMarkdownParser.markdown(from: blocks) == #""" + [Launch%20demo.mp4](/api/files/video-1/Launch%20demo.mp4) + [Briefing.m4a](folder name\Briefing.m4a) + [Spec.pdf](/api/files/pdf-1/Spec.pdf?download=1) + [Archive.zip](/api/files/file-1/Archive.zip#download) + """#) + } + + @Test func markdownExportPreservesAttachmentMimeTypeAsDocmostHTML() { + let block = NativeEditorBlock( + kind: .attachment(NativeEditorAttachmentBlock( + url: "/api/files/file-1/Archive.zip", + name: "Archive.zip", + mimeType: "application/zip", + sizeInBytes: nil, + attachmentID: nil + )), + text: AttributedString("Archive.zip"), + alignment: .left + ) + + #expect( + NativeEditorMarkdownParser.markdown(from: [block]) == + docmostAttachmentHTML( + url: "/api/files/file-1/Archive.zip", + name: "Archive.zip", + mimeType: "application/zip" + ) + ) + } +} + +private func docmostAttachmentHTML(url: String, name: String, mimeType: String) -> String { + let openingTag = htmlTag("div", attributes: [ + ("data-type", "attachment"), + ("data-attachment-url", url), + ("data-attachment-name", name), + ("data-attachment-mime", mimeType) + ]) + return """ + \(openingTag) + \(name) + + """ +} + +private func htmlTag(_ name: String, attributes: [(String, String?)]) -> String { + let attributeText = attributes.compactMap { key, value -> String? in + value.map { #"\#(key)="\#($0)""# } + }.joined(separator: " ") + return "<\(name) \(attributeText)>" +} diff --git a/docmostlyTests/Editor/NativeEditorMentionMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorMentionMarkdownTests.swift index 8b54ab5..3435b96 100644 --- a/docmostlyTests/Editor/NativeEditorMentionMarkdownTests.swift +++ b/docmostlyTests/Editor/NativeEditorMentionMarkdownTests.swift @@ -90,6 +90,75 @@ struct NativeEditorMentionMarkdownTests { #expect(viewModel.markdownForDocument() == "Discuss [Roadmap](/p/abc123#shipping) today") } + @Test func documentMarkdownConversionPreservesUserMentionAtomsAsDocmostHTML() { + var text = AttributedString("Discuss ") + var mentionText = AttributedString("@Taylor") + mentionText[NativeEditorMentionAttribute.self] = NativeEditorMention( + identifier: "mention-1", + label: "Taylor", + entityType: "user", + entityID: "user-1", + creatorID: "creator-1" + ) + text += mentionText + text += AttributedString(" today") + + let block = NativeEditorBlock(kind: .paragraph, text: text, alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + let mentionHTML = userMentionHTML() + + #expect(viewModel.markdownForDocument() == "Discuss \(mentionHTML) today") + } + + @Test func pasteMarkdownDocmostUserMentionHTMLCreatesMentionAtom() throws { + let intro = NativeEditorBlock(kind: .paragraph, text: AttributedString("Intro"), alignment: .left) + let viewModel = configuredViewModel(blocks: [intro]) + viewModel.focus(blockID: intro.id) + let mentionHTML = userMentionHTML() + + viewModel.pasteMarkdown("Discuss \(mentionHTML) today") + + let inlineNodes = viewModel.document.proseMirrorDocument.content.last?.content ?? [] + #expect(inlineNodes.map(\.type) == ["text", "mention", "text"]) + #expect(inlineNodes[0].text == "Discuss ") + #expect(inlineNodes[2].text == " today") + + let attrs = try #require(inlineNodes[1].attrs) + #expect(attrs["id"] == .string("mention-1")) + #expect(attrs["label"] == .string("Taylor")) + #expect(attrs["entityType"] == .string("user")) + #expect(attrs["entityId"] == .string("user-1")) + #expect(attrs["creatorId"] == .string("creator-1")) + } + + @Test func pasteMarkdownDocmostUserMentionHTMLAllowsLiteralSpanTextBeforeClosingSpan() throws { + let intro = NativeEditorBlock(kind: .paragraph, text: AttributedString("Intro"), alignment: .left) + let viewModel = configuredViewModel(blocks: [intro]) + viewModel.focus(blockID: intro.id) + let mentionHTML = #""# + + "@Taylor ``" + + viewModel.pasteMarkdown("Discuss \(mentionHTML) today") + + let inlineNodes = viewModel.document.proseMirrorDocument.content.last?.content ?? [] + #expect(inlineNodes.map(\.type) == ["text", "mention", "text"]) + #expect(inlineNodes[0].text == "Discuss ") + #expect(inlineNodes[2].text == " today") + + let attrs = try #require(inlineNodes[1].attrs) + #expect(attrs["id"] == .string("mention-1")) + #expect(attrs["label"] == .string("Taylor")) + #expect(attrs["entityType"] == .string("user")) + #expect(attrs["entityId"] == .string("user-1")) + } + + private func userMentionHTML() -> String { + #""# + + "@Taylor" + } + private func configuredViewModel(blocks: [NativeEditorBlock]) -> NativeRichEditorViewModel { let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") viewModel.document = NativeEditorDocument(blocks: blocks) diff --git a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift index 829ef90..df9698d 100644 --- a/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift +++ b/docmostlyTests/Editor/NativeEditorRichMarkdownExportTests.swift @@ -4,6 +4,202 @@ import Testing @MainActor struct NativeEditorRichMarkdownExportTests { + @Test func documentMarkdownConversionPreservesIframeEmbedMarkdownShape() { + let source = "https://player.example.com/embed/demo" + let viewModel = configuredViewModel(blocks: [ + NativeEditorBlock( + kind: .embed(NativeEditorEmbedBlock( + source: source, + provider: "iframe", + alignment: nil, + width: nil, + height: nil + )), + text: AttributedString(source), + alignment: .left + ) + ]) + + #expect(viewModel.markdownForDocument() == "[\(source)](\(source))") + } + + @Test func documentMarkdownConversionPreservesSizedIframeEmbedMarkdownShape() { + let source = "https://player.example.com/embed/demo" + let viewModel = configuredViewModel(blocks: [ + NativeEditorBlock( + kind: .embed(NativeEditorEmbedBlock( + source: source, + provider: "iframe", + alignment: "center", + width: "800", + height: "600" + )), + text: AttributedString(source), + alignment: .left + ) + ]) + + #expect(viewModel.markdownForDocument() == "[\(source)](\(source))") + } + + @Test func documentMarkdownConversionPreservesImageTitle() { + let viewModel = configuredViewModel(blocks: [ + NativeEditorBlock( + kind: .image(NativeEditorMediaBlock( + source: "/files/image.png", + alternativeText: "Architecture", + title: "System diagram", + attachmentID: nil, + sizeInBytes: nil, + width: nil, + height: nil, + aspectRatio: nil, + alignment: nil + )), + text: AttributedString("Architecture"), + alignment: .left + ) + ]) + + #expect(viewModel.markdownForDocument() == #"![Architecture](/files/image.png "System diagram")"#) + } + + @Test func documentMarkdownConversionPreservesDocmostPageBreakHTMLShape() { + let viewModel = configuredViewModel(blocks: [ + NativeEditorBlock( + kind: .pageBreak, + text: AttributedString("Page break"), + alignment: .left + ) + ]) + + #expect(viewModel.markdownForDocument() == #"
"#) + } + + @Test func documentMarkdownConversionPreservesDocmostColumnsHTMLShape() { + let viewModel = configuredViewModel(blocks: [ + NativeEditorBlock( + kind: .columns(NativeEditorColumnsBlock( + layout: "three_equal", + widthMode: "wide", + columnCount: 3, + previewText: "Plan Build Ship", + columnTexts: ["Plan", "Build", "Ship"] + )), + text: AttributedString("Plan Build Ship"), + alignment: .left + ) + ]) + + #expect(viewModel.markdownForDocument() == """ +
+
+ Plan +
+
+ Build +
+
+ Ship +
+
+ """) + } + + @Test func documentMarkdownConversionPadsColumnsToColumnCount() { + let viewModel = configuredViewModel(blocks: [ + NativeEditorBlock( + kind: .columns(NativeEditorColumnsBlock( + layout: "three_equal", + widthMode: "normal", + columnCount: 3, + previewText: "Plan", + columnTexts: ["Plan"], + columnWidths: [2] + )), + text: AttributedString("Plan"), + alignment: .left + ) + ]) + + #expect(viewModel.markdownForDocument() == """ +
+
+ Plan +
+
+ +
+
+ +
+
+ """) + } + + @Test func documentMarkdownConversionPreservesDocmostDiagramHTMLShape() { + let drawioSource = "/api/files/drawio-1/diagram.drawio.svg" + let excalidrawSource = "/api/files/excalidraw-1/sketch.png" + let viewModel = configuredViewModel(blocks: [ + NativeEditorBlock( + kind: .drawio(NativeEditorDiagramBlock( + source: drawioSource, + title: "System map", + alternativeText: "System diagram", + attachmentID: "drawio-1", + sizeInBytes: 2_048, + width: "640", + height: "360", + aspectRatio: "1.7777778", + alignment: "center" + )), + text: AttributedString("System map"), + alignment: .left + ), + NativeEditorBlock( + kind: .excalidraw(NativeEditorDiagramBlock( + source: excalidrawSource, + title: "Sketch", + alternativeText: "Whiteboard sketch", + attachmentID: "excalidraw-1", + sizeInBytes: nil, + width: "75%", + height: nil, + aspectRatio: nil, + alignment: "right" + )), + text: AttributedString("Sketch"), + alignment: .left + ) + ]) + + let expected = [ + docmostDiagramHTML( + type: "drawio", + source: drawioSource, + title: "System map", + alternativeText: "System diagram", + attachmentID: "drawio-1", + size: "2048", + width: "640", + height: "360", + aspectRatio: "1.7777778", + alignment: "center" + ), + docmostDiagramHTML( + type: "excalidraw", + source: excalidrawSource, + title: "Sketch", + alternativeText: "Whiteboard sketch", + attachmentID: "excalidraw-1", + width: "75%", + alignment: "right" + ) + ].joined(separator: "\n") + + #expect(viewModel.markdownForDocument() == expected) + } + @Test func documentMarkdownConversionPreservesRichBlockMeaning() { let viewModel = configuredViewModel(blocks: richMarkdownFixtureBlocks()) @@ -16,14 +212,16 @@ struct NativeEditorRichMarkdownExportTests { :::warning Check migration plan ::: -
- Release checklist - +
+ Release checklist +
Ship native editor - +
-
- [Example](https://example.com) +
+ $$ E = mc^2 $$ @@ -37,6 +235,50 @@ struct NativeEditorRichMarkdownExportTests { return viewModel } + private func docmostDiagramHTML( + type: String, + source: String, + title: String, + alternativeText: String, + attachmentID: String, + size: String? = nil, + width: String? = nil, + height: String? = nil, + aspectRatio: String? = nil, + alignment: String? = nil + ) -> String { + let openingTag = htmlTag("div", attributes: [ + ("data-type", type), + ("data-src", source), + ("data-title", title), + ("data-alt", alternativeText), + ("data-width", width), + ("data-height", height), + ("data-size", size), + ("data-aspect-ratio", aspectRatio), + ("data-align", alignment), + ("data-attachment-id", attachmentID) + ]) + let imageTag = htmlTag("img", attributes: [ + ("src", source), + ("alt", alternativeText), + ("width", width) + ]) + + return """ + \(openingTag) + \(imageTag) + + """ + } + + private func htmlTag(_ name: String, attributes: [(String, String?)]) -> String { + let attributeText = attributes.compactMap { key, value -> String? in + value.map { #"\#(key)="\#($0)""# } + }.joined(separator: " ") + return "<\(name) \(attributeText)>" + } + private func richMarkdownFixtureBlocks() -> [NativeEditorBlock] { [ imageMarkdownFixtureBlock(), @@ -126,7 +368,7 @@ struct NativeEditorRichMarkdownExportTests { kind: .attachment(NativeEditorAttachmentBlock( url: "/files/archive.zip", name: "Archive.zip", - mimeType: "application/zip", + mimeType: nil, sizeInBytes: nil, attachmentID: nil )), diff --git a/docmostlyTests/Editor/NativeEditorScriptUnderlineMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorScriptUnderlineMarkdownTests.swift new file mode 100644 index 0000000..92862ec --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorScriptUnderlineMarkdownTests.swift @@ -0,0 +1,76 @@ +import Foundation +import SwiftUI +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorScriptUnderlineMarkdownTests { + @Test func markdownExportPreservesDocmostUnderlineAndScriptMarksAsHTML() { + let block = NativeEditorBlock( + kind: .paragraph, + text: scriptUnderlineFixtureText(), + alignment: .left + ) + + #expect( + NativeEditorMarkdownParser.markdown(from: [block]) == + "Use underline, x2, and H2O" + ) + } + + @Test func markdownImportPreservesDocmostUnderlineAndScriptMarksAsProseMirrorMarks() throws { + let markdown = "Use underline **now**, x2, and H2O" + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + let underlinedRuns = block.text.runs.filter { $0.underlineStyle == .single } + #expect(underlinedRuns.count == 2) + + let boldUnderlinedRun = try #require(underlinedRuns.last) + #expect(String(block.text[boldUnderlinedRun.range].characters) == "now") + #expect(boldUnderlinedRun.inlinePresentationIntent?.contains(.stronglyEmphasized) == true) + + let superscriptRun = try #require(run(in: block.text, matching: "2", baselineOffset: 4)) + #expect(String(block.text[superscriptRun.range].characters) == "2") + + let subscriptRun = try #require(run(in: block.text, matching: "2", baselineOffset: -4)) + #expect(String(block.text[subscriptRun.range].characters) == "2") + + let inlineNodes = try #require(NativeEditorDocument.node(from: block).content) + #expect(inlineNodes.contains { $0.marks?.contains(ProseMirrorMark(type: "underline")) == true }) + #expect(inlineNodes.contains { $0.marks?.contains(ProseMirrorMark(type: "superscript")) == true }) + #expect(inlineNodes.contains { $0.marks?.contains(ProseMirrorMark(type: "subscript")) == true }) + } + + private func scriptUnderlineFixtureText() -> AttributedString { + var text = AttributedString("Use ") + + var underline = AttributedString("underline") + underline.underlineStyle = .single + text += underline + + text += AttributedString(", x") + + var superscript = AttributedString("2") + superscript.baselineOffset = 4 + text += superscript + + text += AttributedString(", and H") + + var subscriptText = AttributedString("2") + subscriptText.baselineOffset = -4 + text += subscriptText + + text += AttributedString("O") + return text + } + + private func run( + in text: AttributedString, + matching value: String, + baselineOffset: Double + ) -> AttributedString.Runs.Run? { + text.runs.first { run in + String(text[run.range].characters) == value && run.baselineOffset == baselineOffset + } + } +} diff --git a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift index 5b73ea1..ec27bc9 100644 --- a/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift +++ b/docmostlyTests/Editor/NativeEditorSlashCommandTests.swift @@ -5,6 +5,11 @@ import Testing @MainActor struct NativeEditorSlashCommandTests { + @Test func slashCommandMenuExposesGenericEmbedCommand() { + #expect(NativeEditorCommand.slashMenuCases.contains(.embed)) + #expect(slashCommandTitles(for: "embed").contains("Embed")) + } + @Test func slashCommandInventoryIncludesBaseColumnsAndProviderEmbeds() { let titles = NativeEditorCommand.allCases.map(\.title) @@ -26,6 +31,180 @@ struct NativeEditorSlashCommandTests { #expect(titles.contains("Google Sheets")) } + @Test func slashCommandInventoryUsesDocmostWebCommandTitles() { + let titles = NativeEditorCommand.allCases.map(\.title) + let expectedTitles = [ + "Text", + "To-do list", + "Heading 1", + "Heading 2", + "Heading 3", + "Bullet list", + "Numbered list", + "Quote", + "Code", + "Divider", + "Page break", + "Image", + "Video", + "Audio", + "Embed PDF", + "File attachment", + "Table", + "Base (Inline)", + "Kanban", + "Toggle block", + "Callout", + "Math inline", + "Math block", + "Mermaid diagram", + "Draw.io (diagrams.net)", + "Excalidraw (Whiteboard)", + "Date", + "Time", + "Status", + "Emoji", + "Subpages (Child pages)", + "Synced block", + "2 Columns", + "3 Columns", + "4 Columns", + "5 Columns", + "Embed", + "Iframe embed", + "Airtable", + "Loom", + "Figma", + "Typeform", + "Miro", + "YouTube", + "Vimeo", + "Framer", + "Google Drive", + "Google Sheets" + ] + + for expectedTitle in expectedTitles { + #expect(titles.contains(expectedTitle)) + } + } + + @Test func slashCommandInventoryFollowsDocmostWebMenuOrder() { + #expect(slashCommandTitles(for: "") == [ + "Text", + "To-do list", + "Heading 1", + "Heading 2", + "Heading 3", + "Bullet list", + "Numbered list", + "Quote", + "Code", + "Divider", + "Page break", + "Image", + "Video", + "Audio", + "Embed PDF", + "File attachment", + "Table", + "Base (Inline)", + "Kanban", + "Toggle block", + "Callout", + "Math inline", + "Math block", + "Mermaid diagram", + "Draw.io (diagrams.net)", + "Excalidraw (Whiteboard)", + "Date", + "Time", + "Status", + "Emoji", + "Subpages (Child pages)", + "Synced block", + "2 Columns", + "3 Columns", + "4 Columns", + "5 Columns", + "Embed", + "Iframe embed", + "Airtable", + "Loom", + "Figma", + "Typeform", + "Miro", + "YouTube", + "Vimeo", + "Framer", + "Google Drive", + "Google Sheets" + ]) + } + + @Test func slashCommandFilteringUsesDocmostSearchTerms() { + let expectations = [ + SlashCommandFilterExpectation(query: "today", title: "Date"), + SlashCommandFilterExpectation(query: "now", title: "Time"), + SlashCommandFilterExpectation(query: "checkbox", title: "To-do list"), + SlashCommandFilterExpectation(query: "hr", title: "Divider"), + SlashCommandFilterExpectation(query: "pagebreak", title: "Page break"), + SlashCommandFilterExpectation(query: "latex", title: "Math inline"), + SlashCommandFilterExpectation(query: "lozenge", title: "Status"), + SlashCommandFilterExpectation(query: "reaction", title: "Emoji") + ] + + for expectation in expectations { + let titles = slashCommandTitles(for: expectation.query) + #expect(titles.contains(expectation.title)) + } + } + + @Test func slashCommandFilteringUsesDocmostFuzzyTitleMatching() { + let expectations = [ + SlashCommandFilterExpectation(query: "tdl", title: "To-do list"), + SlashCommandFilterExpectation(query: "nb", title: "Numbered list"), + SlashCommandFilterExpectation(query: "pgb", title: "Page break") + ] + + for expectation in expectations { + let titles = slashCommandTitles(for: expectation.query) + #expect(titles.contains(expectation.title)) + } + } + + @Test func slashCommandTitleWordStartPriorityScansPastMidWordMatches() { + #expect(NativeEditorCommand.iframeEmbed.matchPriority(query: "e") == 0) + } + + @Test func slashCommandMenuIsDisabledInsideCodeBlocks() { + let block = NativeEditorBlock( + kind: .codeBlock(language: nil), + text: AttributedString("/table"), + alignment: .left + ) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [block]) + viewModel.focus(blockID: block.id) + + #expect(viewModel.isShowingSlashCommands == false) + #expect(viewModel.filteredSlashCommands.isEmpty) + } + + @Test func applyingCodeBlockSlashCommandClearsSlashToken() { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString("/code"), alignment: .left) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.applySlashCommand(.codeBlock) + + #expect(viewModel.document.blocks[0].kind == .codeBlock(language: nil)) + #expect(String(viewModel.document.blocks[0].text.characters).isEmpty) + #expect(viewModel.isShowingSlashCommands == false) + #expect(viewModel.isDirty == true) + } + @Test func applyingColumnSlashCommandsCreatesDocmostColumnLayouts() { let expectations = [ ColumnCommandExpectation(command: .columns, layout: "two_equal", columnCount: 2), @@ -106,6 +285,24 @@ struct NativeEditorSlashCommandTests { } } + @Test func applyingSyncedBlockSlashCommandCreatesDocmostNodeID() throws { + let viewModel = viewModelAfterApplying(.syncedBlock) + let block = viewModel.document.blocks[0] + + guard case .transclusionSource(let source) = block.kind else { + Issue.record("Expected synced block slash command to create a transclusion source") + return + } + + let identifier = try #require(source.identifier) + let node = try #require(viewModel.document.proseMirrorDocument.content.first) + + #expect(identifier.count == 12) + #expect(identifier.unicodeScalars.allSatisfy { (97...122).contains(Int($0.value)) }) + #expect(node.type == "transclusionSource") + #expect(node.attrs?["id"] == .string(identifier)) + } + private func viewModelAfterApplying(_ command: NativeEditorCommand) -> NativeRichEditorViewModel { let block = NativeEditorBlock( kind: .paragraph, @@ -120,6 +317,15 @@ struct NativeEditorSlashCommandTests { return viewModel } + + private func slashCommandTitles(for query: String) -> [String] { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString("/\(query)"), alignment: .left) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [block]) + viewModel.focus(blockID: block.id) + + return viewModel.filteredSlashCommands.map(\.title) + } } private struct ColumnCommandExpectation { @@ -138,3 +344,8 @@ private struct BaseCommandExpectation { let command: NativeEditorCommand let previewText: String } + +private struct SlashCommandFilterExpectation { + let query: String + let title: String +} diff --git a/docmostlyTests/Editor/NativeEditorStatusMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorStatusMarkdownTests.swift new file mode 100644 index 0000000..653c99e --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorStatusMarkdownTests.swift @@ -0,0 +1,74 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorStatusMarkdownTests { + @Test func markdownExportPreservesStatusAtomAsDocmostHTML() { + var text = AttributedString("Stage ") + var statusText = AttributedString("Blocked") + statusText[NativeEditorStatusAttribute.self] = NativeEditorStatusBadge(text: "Blocked", color: "red") + text += statusText + text += AttributedString(" now") + + let block = NativeEditorBlock(kind: .paragraph, text: text, alignment: .left) + + #expect( + NativeEditorMarkdownParser.markdown(from: [block]) == + #"Stage Blocked now"# + ) + } + + @Test func markdownImportPreservesDocmostStatusHTMLAsAtom() throws { + let block = try #require(NativeEditorMarkdownParser.blocks( + from: #"Stage Ship now"# + ).first) + + let statusRun = try #require(block.text.runs.first { run in + run[NativeEditorStatusAttribute.self]?.text == "Ship" + }) + #expect(statusRun[NativeEditorStatusAttribute.self]?.color == "green") + + let inlineNodes = try #require(NativeEditorDocument.node(from: block).content) + #expect(inlineNodes.map(\.type) == ["text", "status", "text"]) + #expect(inlineNodes[0].text == "Stage ") + #expect(inlineNodes[2].text == " now") + #expect(inlineNodes[1].attrs?["text"] == .string("Ship")) + #expect(inlineNodes[1].attrs?["color"] == .string("green")) + } + + @Test func markdownImportSkipsMalformedStatusSpanAndPreservesLaterStatusAtom() throws { + let markdown = #"Stage Broken "# + + #"Ship now"# + let block = try #require(NativeEditorMarkdownParser.blocks( + from: markdown + ).first) + + let statusRun = try #require(block.text.runs.first { run in + run[NativeEditorStatusAttribute.self]?.text == "Ship" + }) + #expect(statusRun[NativeEditorStatusAttribute.self]?.color == "green") + + let inlineNodes = try #require(NativeEditorDocument.node(from: block).content) + #expect(inlineNodes.contains { node in + node.type == "status" && + node.attrs?["text"] == .string("Ship") && + node.attrs?["color"] == .string("green") + }) + } + + @Test func markdownImportRecoversFromRepeatedMalformedStatusSpans() throws { + let malformedSpans = Array( + repeating: #"Broken "#, + count: 25 + ).joined() + let block = try #require(NativeEditorMarkdownParser.blocks( + from: malformedSpans + #"Ship"# + ).first) + + let statusRun = try #require(block.text.runs.first { run in + run[NativeEditorStatusAttribute.self]?.text == "Ship" + }) + #expect(statusRun[NativeEditorStatusAttribute.self]?.color == "green") + } +} diff --git a/docmostlyTests/Editor/NativeEditorStructuralHTMLFidelityTests.swift b/docmostlyTests/Editor/NativeEditorStructuralHTMLFidelityTests.swift new file mode 100644 index 0000000..3244b28 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorStructuralHTMLFidelityTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorStructuralHTMLFidelityTests { + @Test func documentMarkdownConversionPreservesDocmostStructuralHTMLBlocks() { + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [ + NativeEditorBlock(kind: .subpages, text: AttributedString("Subpages"), alignment: .left), + NativeEditorBlock( + kind: .transclusionSource(NativeEditorTransclusionSourceBlock( + identifier: "sync-1", + previewText: "Reusable launch checklist" + )), + text: AttributedString("Reusable launch checklist"), + alignment: .left + ), + NativeEditorBlock( + kind: .transclusionReference(NativeEditorTransclusionReferenceBlock( + sourcePageID: "page-1", + transclusionID: "sync-1" + )), + text: AttributedString("Reusable launch checklist"), + alignment: .left + ), + NativeEditorBlock( + kind: .base(NativeEditorBaseBlock( + pageID: "base-page-1", + pendingKey: nil, + previewText: "Roadmap base" + )), + text: AttributedString("Roadmap base"), + alignment: .left + ) + ]) + viewModel.resetEditingHistory() + + #expect(viewModel.markdownForDocument() == """ +
+
+ Reusable launch checklist +
+
+
+ """) + } +} diff --git a/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift b/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift new file mode 100644 index 0000000..d2cc395 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorTableHTMLImportTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorTableHTMLImportTests { + @Test func docmostHTMLTableImportsAsNativeTableBlock() throws { + let markdown = """ +
+ + + + + + + + + + + + +
+

Phase

+

Build

Taylor

+
+ """ + + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + guard case .table(let table) = block.kind else { + Issue.record("Expected Docmost HTML table to import as a native table block.") + return + } + + #expect(table.rows.count == 2) + #expect(table.rows[0].cells.count == 1) + #expect(table.rows[0].cells[0].plainText == "Phase") + #expect(table.rows[0].cells[0].isHeader) + #expect(table.rows[0].cells[0].textAlignment == .center) + #expect(table.rows[0].cells[0].backgroundColor == "#DBEAFE") + #expect(table.rows[0].cells[0].backgroundColorName == "blue") + #expect(table.rows[0].cells[0].columnSpan == 2) + #expect(table.rows[0].cells[0].columnWidths == [120, 160]) + #expect(table.rows[1].cells.map(\.plainText) == ["Build", "Taylor"]) + #expect(table.rows[1].cells[0].backgroundColor == "#FEF3C7") + #expect(table.rows[1].cells[0].backgroundColorName == "yellow") + + let node = NativeEditorDocument.node(from: block) + #expect(node.type == "table") + let headerCell = try #require(node.content?.first?.content?.first) + #expect(headerCell.type == "tableHeader") + #expect(headerCell.attrs?["colspan"] == .int(2)) + #expect(headerCell.attrs?["colwidth"] == .array([.int(120), .int(160)])) + #expect(headerCell.attrs?["backgroundColor"] == .string("#DBEAFE")) + #expect(headerCell.attrs?["backgroundColorName"] == .string("blue")) + } +} diff --git a/docmostlyTests/Editor/NativeEditorTableMarkdownExportTests.swift b/docmostlyTests/Editor/NativeEditorTableMarkdownExportTests.swift new file mode 100644 index 0000000..7c21112 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorTableMarkdownExportTests.swift @@ -0,0 +1,93 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorTableMarkdownExportTests { + @Test func markdownTableRoundTripPreservesLiteralBackslashesInCells() throws { + let table = NativeEditorTable(rows: [ + NativeEditorTableRow(cells: [ + NativeEditorTableCell(plainText: "Path", isHeader: true, backgroundColorName: nil) + ]), + NativeEditorTableRow(cells: [ + NativeEditorTableCell(plainText: #"C:\Temp\spec.txt"#, isHeader: false, backgroundColorName: nil) + ]) + ]) + let block = NativeEditorBlock(kind: .table(table), text: AttributedString("Table"), alignment: .left) + + let markdown = NativeEditorMarkdownParser.markdown(from: [block]) + let importedBlock = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + guard case .table(let importedTable) = importedBlock.kind else { + Issue.record("Expected table Markdown to reimport as a table.") + return + } + + #expect(importedTable.rows[1].cells[0].plainText == #"C:\Temp\spec.txt"#) + #expect(NativeEditorMarkdownParser.markdown(from: [importedBlock]) == markdown) + } + + @Test func markdownExportPreservesInlineMarksInsideAlignedTableCells() throws { + let document = try NativeEditorDocument(proseMirrorJSONData: Data(""" + { + "type": "doc", + "content": [ + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "content": [ + { + "type": "paragraph", + "attrs": { "textAlign": "center" }, + "content": [{ "type": "text", "text": "File" }] + } + ] + } + ] + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "attrs": { "textAlign": "center" }, + "content": [ + { + "type": "text", + "text": "Spec", + "marks": [ + { + "type": "link", + "attrs": { "href": "/api/files/file-1/Spec.pdf" } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + """.utf8)) + + #expect( + NativeEditorMarkdownParser.markdown(from: document.blocks) == + """ + | File | + | :---: | + | [Spec](/api/files/file-1/Spec.pdf) | + """ + ) + } +} diff --git a/docmostlyTests/Editor/NativeEditorTableMarkdownImportTests.swift b/docmostlyTests/Editor/NativeEditorTableMarkdownImportTests.swift new file mode 100644 index 0000000..0300181 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorTableMarkdownImportTests.swift @@ -0,0 +1,27 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorTableMarkdownImportTests { + @Test func markdownTableImportPreservesPipesInsideCodeSpans() throws { + let blocks = NativeEditorMarkdownParser.blocks(from: """ + | Expression | Result | + | --- | --- | + | `a | b` | Ready | + """) + + let block = try #require(blocks.first) + guard case .table(let table) = block.kind else { + Issue.record("Expected markdown table") + return + } + + #expect(table.rows.count == 2) + #expect(table.rows[1].cells[0].plainText == "a | b") + #expect(table.rows[1].cells[1].plainText == "Ready") + #expect(table.rows[1].cells[0].inlineContent == [ + .text("a | b", marks: [.code]) + ]) + } +} diff --git a/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift b/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift index 7239858..262e50e 100644 --- a/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift +++ b/docmostlyTests/Editor/NativeEditorTablePayloadTests.swift @@ -53,4 +53,138 @@ struct NativeEditorTablePayloadTests { #expect(cell.columnWidths == [120, 160]) #expect(cell.backgroundColorName == "blue") } + + @Test func preservesTableCellBackgroundColorAttribute() throws { + let data = Data(""" + { + "type": "doc", + "content": [ + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "attrs": { + "backgroundColor": "rgb(254, 243, 199)", + "backgroundColorName": "yellow" + }, + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Risk" }] + } + ] + } + ] + } + ] + } + ] + } + """.utf8) + + let document = try NativeEditorDocument(proseMirrorJSONData: data) + + guard case .table(let table) = document.blocks.first?.kind else { + Issue.record("Expected table block") + return + } + + let cell = try #require(table.rows.first?.cells.first) + #expect(cell.backgroundColor == "rgb(254, 243, 199)") + #expect(cell.backgroundColorName == "yellow") + + let reencodedDocument = NativeEditorDocument(blocks: [ + NativeEditorBlock(kind: .table(table), text: AttributedString("Table"), alignment: .left) + ]) + let encodedCell = try #require( + reencodedDocument.proseMirrorDocument.content.first?.content?.first?.content?.first + ) + #expect(encodedCell.attrs?["backgroundColor"] == .string("rgb(254, 243, 199)")) + #expect(encodedCell.attrs?["backgroundColorName"] == .string("yellow")) + } + + @Test func editingTableCellPreservesParagraphAttributesInOtherCells() throws { + let data = Data(""" + { + "type": "doc", + "content": [ + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "attrs": { "textAlign": "right" }, + "content": [{ "type": "text", "text": "Metric" }] + } + ] + }, + { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Draft" }] + } + ] + } + ] + } + ] + } + ] + } + """.utf8) + + let document = try NativeEditorDocument(proseMirrorJSONData: data) + + guard case .table(var table) = document.blocks.first?.kind else { + Issue.record("Expected table block") + return + } + + table.rows[0].cells[1].plainText = "Ready" + table.rows[0].cells[1].inlineContent = nil + table.rows[0].cells[1].preservedContent = nil + + let reencodedDocument = NativeEditorDocument(blocks: [ + NativeEditorBlock(kind: .table(table), text: AttributedString("Table"), alignment: .left) + ]) + let firstCell = try #require( + reencodedDocument.proseMirrorDocument.content.first?.content?.first?.content?.first + ) + let firstParagraph = try #require(firstCell.content?.first) + + #expect(firstParagraph.attrs?["textAlign"] == .string("right")) + #expect(firstParagraph.content?.first?.text == "Metric") + } + + @MainActor + @Test func tableCellBackgroundRespectsRGBAAlphaPercentages() { + let components = NativeEditorTableLayout.cssRGBAComponents(from: "rgba(255, 0, 0, 50%)") + + #expect(components?.red == 255) + #expect(components?.green == 0) + #expect(components?.blue == 0) + #expect(components?.opacity == 0.5) + } + + @MainActor + @Test func tableCellBackgroundRespectsRGBColorPercentages() { + let components = NativeEditorTableLayout.cssRGBAComponents(from: "rgb(100%, 0%, 50%)") + + #expect(components?.red == 255) + #expect(components?.green == 0) + #expect(components?.blue == 127.5) + #expect(components?.opacity == 1) + } } diff --git a/docmostlyTests/Editor/NativeEditorTextColorMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorTextColorMarkdownTests.swift new file mode 100644 index 0000000..863dd07 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorTextColorMarkdownTests.swift @@ -0,0 +1,81 @@ +import Foundation +import SwiftUI +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorTextColorMarkdownTests { + @Test func markdownExportPreservesDocmostTextColorMarkAsHTML() { + var text = AttributedString("Review ") + var colored = AttributedString("important") + colored[NativeEditorTextColorAttribute.self] = "#2563EB" + colored.foregroundColor = Color(docmostlyHex: "#2563EB") + text += colored + text += AttributedString(" today") + + let block = NativeEditorBlock(kind: .paragraph, text: text, alignment: .left) + + #expect( + NativeEditorMarkdownParser.markdown(from: [block]) == + ##"Review important today"## + ) + } + + @Test func markdownImportPreservesDocmostTextColorSpanAsProseMirrorMark() throws { + let markdown = ##"Review important **copy** today"## + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + let coloredRuns = block.text.runs.filter { run in + run[NativeEditorTextColorAttribute.self] == "#2563EB" + } + #expect(coloredRuns.count == 2) + + let boldRun = try #require(coloredRuns.last) + #expect(String(block.text[boldRun.range].characters) == "copy") + #expect(boldRun.inlinePresentationIntent?.contains(.stronglyEmphasized) == true) + + let inlineNodes = try #require(NativeEditorDocument.node(from: block).content) + let textColorMark = ProseMirrorMark( + type: "textStyle", + attrs: ["color": .string("#2563EB")] + ) + #expect(inlineNodes.contains { $0.marks?.contains(textColorMark) == true }) + } + + @Test func markdownImportIgnoresTextColorHTMLInsideCodeSpans() throws { + let markdown = ##"Keep `literal` then "## + + ##"real"## + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + let codeRun = try #require(block.text.runs.first { run in + String(block.text[run.range].characters) == #"literal"# + }) + #expect(codeRun.inlinePresentationIntent?.contains(.code) == true) + #expect(codeRun[NativeEditorTextColorAttribute.self] == nil) + + let coloredRun = try #require(block.text.runs.first { run in + String(block.text[run.range].characters) == "real" + }) + #expect(coloredRun[NativeEditorTextColorAttribute.self] == "#2563EB") + } + + @Test func markdownImportPreservesNestedTextColorSpans() throws { + let markdown = ##"outer "## + + ##"inner tail"## + let block = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + let outerRun = try #require(block.text.runs.first { run in + String(block.text[run.range].characters) == "outer " + }) + let innerRun = try #require(block.text.runs.first { run in + String(block.text[run.range].characters) == "inner" + }) + let tailRun = try #require(block.text.runs.first { run in + String(block.text[run.range].characters) == " tail" + }) + + #expect(outerRun[NativeEditorTextColorAttribute.self] == "#111827") + #expect(innerRun[NativeEditorTextColorAttribute.self] == "#2563EB") + #expect(tailRun[NativeEditorTextColorAttribute.self] == "#111827") + } +} diff --git a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift index ab8e00a..8b7f121 100644 --- a/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorMechanicsTests.swift @@ -68,6 +68,155 @@ struct NativeRichEditorMechanicsTests { #expect(viewModel.markdownForDocument() == "---") } + @Test func markdownInputRuleSupportsDocmostDetailsShortcut() throws { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(""), alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.document.blocks[0].text = AttributedString(":::details ") + viewModel.handleDocumentChanged() + + guard case .details(let details) = viewModel.document.blocks[0].kind else { + Issue.record("Expected Docmost details shortcut to create a native details block.") + return + } + #expect(details.summary == "Details") + #expect(details.previewText == "Details") + #expect(String(viewModel.document.blocks[0].text.characters) == "Details") + + let node = viewModel.document.proseMirrorDocument.content[0] + #expect(node.type == "details") + #expect(node.attrs?["open"] == .bool(true)) + #expect(node.content?.first?.type == "detailsSummary") + #expect(node.content?.first?.content?.first?.text == "Details") + #expect(node.content?[1].type == "detailsContent") + #expect(node.content?[1].content?.first?.content?.first?.text == "Details") + + viewModel.undo() + + #expect(viewModel.document.blocks[0].kind == .paragraph) + #expect(String(viewModel.document.blocks[0].text.characters).isEmpty) + } + + @Test func markdownImportSupportsDocmostDetailsShortcutAfterLineTrimming() throws { + let block = try #require(NativeEditorMarkdownParser.blocks(from: ":::details ").first) + + guard case .details(let details) = block.kind else { + Issue.record("Expected imported Docmost details shortcut to create a native details block.") + return + } + + #expect(details.summary == "Details") + #expect(details.previewText == "Details") + #expect(NativeEditorDocument.node(from: block).type == "details") + } + + @Test func markdownInputRuleSupportsDocmostDefaultCalloutShortcut() { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(""), alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.document.blocks[0].text = AttributedString("::: ") + viewModel.handleDocumentChanged() + + guard case .callout(let callout) = viewModel.document.blocks[0].kind else { + Issue.record("Expected Docmost callout shortcut to create a native callout block.") + return + } + #expect(callout.style == "info") + #expect(callout.previewText == "Callout") + #expect(String(viewModel.document.blocks[0].text.characters) == "Callout") + #expect(viewModel.document.proseMirrorDocument.content[0].type == "callout") + #expect(viewModel.document.proseMirrorDocument.content[0].attrs?["type"] == .string("info")) + } + + @Test func markdownInputRuleSupportsDocmostTypedCalloutShortcut() { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(""), alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.document.blocks[0].text = AttributedString(":::warning ") + viewModel.handleDocumentChanged() + + guard case .callout(let callout) = viewModel.document.blocks[0].kind else { + Issue.record("Expected Docmost typed callout shortcut to create a native callout block.") + return + } + #expect(callout.style == "warning") + #expect(String(viewModel.document.blocks[0].text.characters) == "Callout") + #expect(viewModel.document.proseMirrorDocument.content[0].attrs?["type"] == .string("warning")) + } + + @Test func markdownInputRuleSupportsDocmostMathBlockShortcut() { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(""), alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.document.blocks[0].text = AttributedString("$$$E = mc^2$$$") + viewModel.handleDocumentChanged() + + guard case .mathBlock(let math) = viewModel.document.blocks[0].kind else { + Issue.record("Expected Docmost math shortcut to create a native math block.") + return + } + #expect(math.text == "E = mc^2") + #expect(String(viewModel.document.blocks[0].text.characters) == "E = mc^2") + #expect(viewModel.document.proseMirrorDocument.content[0].type == "mathBlock") + #expect(viewModel.document.proseMirrorDocument.content[0].attrs?["text"] == .string("E = mc^2")) + } + + @Test func markdownInputRuleSupportsDocmostInlineMathShortcut() throws { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(""), alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.document.blocks[0].text = AttributedString("Area $$A = pi r^2$$") + viewModel.handleDocumentChanged() + + #expect(viewModel.document.blocks[0].kind == .paragraph) + #expect(String(viewModel.document.blocks[0].text.characters) == "Area A = pi r^2") + + let inlineNodes = try #require(viewModel.document.proseMirrorDocument.content.first?.content) + #expect(inlineNodes.map(\.type) == ["text", "mathInline"]) + #expect(inlineNodes[0].text == "Area ") + #expect(inlineNodes[1].attrs?["text"] == .string("A = pi r^2")) + + viewModel.undo() + + #expect(viewModel.document.blocks[0].kind == .paragraph) + #expect(String(viewModel.document.blocks[0].text.characters).isEmpty) + } + + @Test func markdownInputRuleSupportsDocmostInlineMarkShortcuts() throws { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(""), alignment: .left) + let viewModel = configuredViewModel(blocks: [block]) + viewModel.focus(blockID: block.id) + + viewModel.document.blocks[0].text = AttributedString("Use **bold** *italic* `code` and ~~strike~~") + viewModel.handleDocumentChanged() + + #expect(String(viewModel.document.blocks[0].text.characters) == "Use bold italic code and strike") + + let inlineNodes = try #require(viewModel.document.proseMirrorDocument.content.first?.content) + #expect(inlineNodes.contains { + $0.text == "bold" && $0.marks?.contains(ProseMirrorMark(type: "bold")) == true + }) + #expect(inlineNodes.contains { + $0.text == "italic" && $0.marks?.contains(ProseMirrorMark(type: "italic")) == true + }) + #expect(inlineNodes.contains { + $0.text == "code" && $0.marks?.contains(ProseMirrorMark(type: "code")) == true + }) + #expect(inlineNodes.contains { + $0.text == "strike" && $0.marks?.contains(ProseMirrorMark(type: "strike")) == true + }) + + viewModel.undo() + + #expect(viewModel.document.blocks[0].kind == .paragraph) + #expect(String(viewModel.document.blocks[0].text.characters).isEmpty) + } + @Test func pasteMarkdownInsertsNativeBlocksAfterActiveBlock() { let intro = NativeEditorBlock(kind: .paragraph, text: AttributedString("Intro"), alignment: .left) let viewModel = configuredViewModel(blocks: [intro]) @@ -131,7 +280,8 @@ struct NativeRichEditorMechanicsTests { #expect(viewModel.document.blocks.count == 2) - guard case .table(let table) = viewModel.document.blocks[1].kind else { + let tableBlock = try #require(viewModel.document.blocks.dropFirst().first) + guard case .table(let table) = tableBlock.kind else { Issue.record("Expected pasted Markdown table to become a native table block.") return } @@ -159,6 +309,38 @@ struct NativeRichEditorMechanicsTests { """) } + @Test func pasteMarkdownTablePreservesInlineMarksInCells() throws { + let intro = NativeEditorBlock(kind: .paragraph, text: AttributedString("Intro"), alignment: .left) + let viewModel = configuredViewModel(blocks: [intro]) + viewModel.focus(blockID: intro.id) + + viewModel.pasteMarkdown(""" + | Feature | Source | + | --- | --- | + | **Tables** | [Spec](https://example.com/spec) | + """) + + guard case .table(let table) = viewModel.document.blocks[1].kind else { + Issue.record("Expected pasted Markdown table to become a native table block.") + return + } + + #expect(table.rows[1].cells.map(\.plainText) == ["Tables", "Spec"]) + + let bodyRow = try #require(viewModel.document.proseMirrorDocument.content.last?.content?.dropFirst().first) + let firstCellText = try #require(bodyRow.content?[0].content?.first?.content?.first) + let secondCellText = try #require(bodyRow.content?[1].content?.first?.content?.first) + + #expect(firstCellText.text == "Tables") + #expect(firstCellText.marks?.contains(ProseMirrorMark(type: "bold")) == true) + #expect(secondCellText.text == "Spec") + #expect( + secondCellText.marks?.contains( + ProseMirrorMark(type: "link", attrs: ["href": .string("https://example.com/spec")]) + ) == true + ) + } + @Test func pasteMarkdownRichBlocksCreatesNativeBlocks() throws { let intro = NativeEditorBlock(kind: .paragraph, text: AttributedString("Intro"), alignment: .left) let viewModel = configuredViewModel(blocks: [intro]) diff --git a/docmostlyTests/Editor/NativeRichEditorStructuralBlockTests.swift b/docmostlyTests/Editor/NativeRichEditorStructuralBlockTests.swift index 0ba284d..29ebf5c 100644 --- a/docmostlyTests/Editor/NativeRichEditorStructuralBlockTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorStructuralBlockTests.swift @@ -25,6 +25,69 @@ struct NativeRichEditorStructuralBlockTests { #expect(node.content?[2].content?.first?.content?.first?.text == "Ship") } + @Test func columnsNodePadsTextsAndWidthsToColumnCount() { + let columns = NativeEditorColumnsBlock( + layout: "three_equal", + widthMode: "normal", + columnCount: 3, + previewText: "Plan", + columnTexts: ["Plan"], + columnWidths: [2] + ) + let node = NativeEditorRichBlockNodeFactory.columnsNode(from: columns) + + #expect(node.content?.count == 3) + #expect(node.content?[0].attrs?["width"] == .int(2)) + #expect(node.content?[1].attrs?["width"] == .int(1)) + #expect(node.content?[2].attrs?["width"] == .int(1)) + #expect(node.content?[0].content?.first?.content?.first?.text == "Plan") + #expect(node.content?[1].content?.first?.content?.first?.text == nil) + #expect(node.content?[2].content?.first?.content?.first?.text == nil) + } + + @Test func columnsBlockEqualityNormalizesMissingWidths() { + let columnsWithoutWidths = NativeEditorColumnsBlock( + layout: "three_equal", + widthMode: "normal", + columnCount: 3, + previewText: "Plan", + columnTexts: ["Plan"] + ) + let columnsWithNilWidths = NativeEditorColumnsBlock( + layout: "three_equal", + widthMode: "normal", + columnCount: 3, + previewText: "Plan", + columnTexts: ["Plan"], + columnWidths: [nil, nil, nil] + ) + + #expect(columnsWithoutWidths == columnsWithNilWidths) + #expect(Set([columnsWithoutWidths, columnsWithNilWidths]).count == 1) + } + + @Test func columnsNormalizationClampsDeclaredCountToDocmostMaximum() { + let malformedColumns = NativeEditorColumnsBlock( + layout: "five_equal", + widthMode: "normal", + columnCount: 999, + previewText: "Plan", + columnTexts: ["Plan"] + ) + let maximumColumns = NativeEditorColumnsBlock( + layout: "five_equal", + widthMode: "normal", + columnCount: 5, + previewText: "Plan", + columnTexts: ["Plan"] + ) + + let node = NativeEditorRichBlockNodeFactory.columnsNode(from: malformedColumns) + #expect(node.content?.count == 5) + #expect(malformedColumns == maximumColumns) + #expect(Set([malformedColumns, maximumColumns]).count == 1) + } + @Test func updatesSyncedBlockIdentifiers() { let viewModel = structuralBlockViewModel() let sourceID = viewModel.document.blocks[1].id diff --git a/docmostlyTests/Editor/NativeRichEditorTableTests.swift b/docmostlyTests/Editor/NativeRichEditorTableTests.swift index 737bbe5..6df6d77 100644 --- a/docmostlyTests/Editor/NativeRichEditorTableTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorTableTests.swift @@ -4,6 +4,28 @@ import Testing @MainActor struct NativeRichEditorTableTests { + @Test func tableSlashCommandCreatesDocmostDefaultTableShape() throws { + let viewModel = tableViewModel() + + guard case .table(let table) = viewModel.document.blocks[0].kind else { + Issue.record("Expected table block") + return + } + + #expect(table.rows.count == 3) + #expect(table.columnCount == 3) + #expect(table.rows.first?.cells.allSatisfy(\.isHeader) == true) + #expect(table.rows.dropFirst().flatMap(\.cells).allSatisfy { $0.isHeader == false }) + #expect(table.rows.flatMap(\.cells).allSatisfy { $0.plainText.isEmpty }) + + let tableNode = try #require(viewModel.document.proseMirrorDocument.content.first) + #expect(tableNode.content?.count == 3) + #expect(tableNode.content?.first?.content?.map(\.type) == ["tableHeader", "tableHeader", "tableHeader"]) + #expect(tableNode.content?.dropFirst().allSatisfy { row in + row.content?.allSatisfy { $0.type == "tableCell" } == true + } == true) + } + @Test func updatesTableCellAndReencodesRawTableNode() { let viewModel = tableViewModel() let blockID = viewModel.document.blocks[0].id @@ -38,8 +60,8 @@ struct NativeRichEditorTableTests { Issue.record("Expected table block") return } - #expect(expandedTable.rows.count == 3) - #expect(expandedTable.columnCount == 3) + #expect(expandedTable.rows.count == 4) + #expect(expandedTable.columnCount == 4) #expect(expandedTable.rows[0].cells[1].isHeader == true) #expect(expandedTable.rows[1].cells.allSatisfy { $0.isHeader == false }) @@ -50,9 +72,9 @@ struct NativeRichEditorTableTests { Issue.record("Expected table block") return } - #expect(reducedTable.rows.count == 2) - #expect(reducedTable.columnCount == 2) - #expect(viewModel.document.proseMirrorDocument.content.first?.content?.count == 2) + #expect(reducedTable.rows.count == 3) + #expect(reducedTable.columnCount == 3) + #expect(viewModel.document.proseMirrorDocument.content.first?.content?.count == 3) } @Test func updatesTableColumnWidthAndReencodesCellColwidth() { @@ -78,6 +100,7 @@ struct NativeRichEditorTableTests { NativeEditorTableCell( plainText: "Merged", isHeader: true, + backgroundColor: "rgb(219, 234, 254)", backgroundColorName: "blue", columnWidth: 120, columnSpan: 2, @@ -102,10 +125,295 @@ struct NativeRichEditorTableTests { #expect(firstCell?.attrs?["colspan"] == .int(2)) #expect(firstCell?.attrs?["rowspan"] == .int(2)) #expect(firstCell?.attrs?["colwidth"] == .array([.int(120), .int(160)])) + #expect(firstCell?.attrs?["backgroundColor"] == .string("rgb(219, 234, 254)")) #expect(firstCell?.attrs?["backgroundColorName"] == .string("blue")) #expect(firstCell?.content?.first?.content?.first?.text == "Updated") } + @Test func editingAlignedTableCellPreservesParagraphAlignmentAttribute() throws { + let data = Data(""" + { + "type": "doc", + "content": [ + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "attrs": { "textAlign": "center" }, + "content": [{ "type": "text", "text": "Status" }] + } + ] + } + ] + } + ] + } + ] + } + """.utf8) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = try NativeEditorDocument(proseMirrorJSONData: data) + let blockID = try #require(viewModel.document.blocks.first?.id) + + viewModel.updateTableCell(blockID: blockID, rowIndex: 0, columnIndex: 0, text: "Ready") + + let cell = try #require(viewModel.document.proseMirrorDocument.content.first?.content?.first?.content?.first) + let paragraph = try #require(cell.content?.first) + #expect(paragraph.attrs?["textAlign"] == .string("center")) + #expect(paragraph.content?.first?.text == "Ready") + } + + @Test func editingTableCellPreservesInlineMarksInOtherCells() throws { + let data = Data(""" + { + "type": "doc", + "content": [ + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Feature" }] + } + ] + }, + { + "type": "tableHeader", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Status" }] + } + ] + } + ] + }, + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Tables", + "marks": [{ "type": "bold" }] + } + ] + } + ] + }, + { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Draft" }] + } + ] + } + ] + } + ] + } + ] + } + """.utf8) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = try NativeEditorDocument(proseMirrorJSONData: data) + let blockID = try #require(viewModel.document.blocks.first?.id) + + viewModel.updateTableCell(blockID: blockID, rowIndex: 1, columnIndex: 1, text: "Ready") + + let bodyRow = try #require(viewModel.document.proseMirrorDocument.content.first?.content?.dropFirst().first) + let preservedText = try #require(bodyRow.content?.first?.content?.first?.content?.first) + #expect(preservedText.text == "Tables") + #expect(preservedText.marks?.contains(ProseMirrorMark(type: "bold")) == true) + } + + @Test func markdownExportPreservesRelativeLinksInsideTableCells() { + let table = NativeEditorTable(rows: [ + NativeEditorTableRow(cells: [ + NativeEditorTableCell(plainText: "File", isHeader: true, backgroundColorName: nil) + ]), + NativeEditorTableRow(cells: [ + NativeEditorTableCell( + plainText: "Spec", + inlineContent: [ + .text("Spec", marks: [.link(href: "/api/files/file-1/Spec.pdf")]) + ], + isHeader: false, + backgroundColorName: nil + ) + ]) + ]) + + #expect( + NativeEditorMarkdownParser.tableMarkdown(from: table) == + """ + | File | + | --- | + | [Spec](/api/files/file-1/Spec.pdf) | + """ + ) + } + + @Test func markdownExportWrapsUnsafeRelativeLinksInsideTableCells() { + let table = NativeEditorTable(rows: [ + NativeEditorTableRow(cells: [ + NativeEditorTableCell(plainText: "File", isHeader: true, backgroundColorName: nil) + ]), + NativeEditorTableRow(cells: [ + NativeEditorTableCell( + plainText: "Spec", + inlineContent: [ + .text("Spec", marks: [.link(href: "/p/project plan)/Spec.pdf")]) + ], + isHeader: false, + backgroundColorName: nil + ) + ]) + ]) + + #expect( + NativeEditorMarkdownParser.tableMarkdown(from: table) == + """ + | File | + | --- | + | [Spec](

) | + """ + ) + } + + @Test func markdownExportDoesNotDoubleEscapeAngleWrappedTableLinkBackslashes() { + let table = NativeEditorTable(rows: [ + NativeEditorTableRow(cells: [ + NativeEditorTableCell(plainText: "File", isHeader: true, backgroundColorName: nil) + ]), + NativeEditorTableRow(cells: [ + NativeEditorTableCell( + plainText: "Spec", + inlineContent: [ + .text("Spec", marks: [.link(href: #"folder name\doc"#)]) + ], + isHeader: false, + backgroundColorName: nil + ) + ]) + ]) + + #expect( + NativeEditorMarkdownParser.tableMarkdown(from: table) == + #""" + | File | + | --- | + | [Spec]() | + """# + ) + } + + @Test func markdownImportExportPreservesTableColumnAlignmentMarkers() throws { + let blocks = NativeEditorMarkdownParser.blocks(from: """ + | Metric | Owner | Status | + | :--- | :---: | ---: | + | Launch | Taylor | Ready | + """) + + let block = try #require(blocks.first) + guard case .table(let table) = block.kind else { + Issue.record("Expected table block") + return + } + + #expect( + NativeEditorMarkdownParser.tableMarkdown(from: table) == + """ + | Metric | Owner | Status | + | :--- | :---: | ---: | + | Launch | Taylor | Ready | + """ + ) + + let rows = try #require(block.rawNode?.content) + let headerCells = try #require(rows.first?.content) + let bodyCells = try #require(rows.dropFirst().first?.content) + #expect(headerCells[0].content?.first?.attrs?["textAlign"] == .string("left")) + #expect(headerCells[1].content?.first?.attrs?["textAlign"] == .string("center")) + #expect(headerCells[2].content?.first?.attrs?["textAlign"] == .string("right")) + #expect(bodyCells[0].content?.first?.attrs?["textAlign"] == .string("left")) + #expect(bodyCells[1].content?.first?.attrs?["textAlign"] == .string("center")) + #expect(bodyCells[2].content?.first?.attrs?["textAlign"] == .string("right")) + } + + @Test func editingTableCellPreservesUnsupportedRichContentInOtherCells() throws { + let data = Data(""" + { + "type": "doc", + "content": [ + { + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableCell", + "content": [ + { + "type": "image", + "attrs": { + "src": "/files/table-image.png", + "alt": "Architecture" + } + } + ] + }, + { + "type": "tableCell", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Draft" }] + } + ] + } + ] + } + ] + } + ] + } + """.utf8) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = try NativeEditorDocument(proseMirrorJSONData: data) + let blockID = try #require(viewModel.document.blocks.first?.id) + + viewModel.updateTableCell(blockID: blockID, rowIndex: 0, columnIndex: 1, text: "Ready") + + let firstRow = try #require(viewModel.document.proseMirrorDocument.content.first?.content?.first) + let firstCell = try #require(firstRow.content?.first) + let preservedImage = try #require(firstCell.content?.first) + #expect(preservedImage.type == "image") + #expect(preservedImage.attrs?["src"] == .string("/files/table-image.png")) + #expect(preservedImage.attrs?["alt"] == .string("Architecture")) + } + private func tableViewModel() -> NativeRichEditorViewModel { let block = NativeEditorBlock(kind: .paragraph, text: AttributedString("/table"), alignment: .left) let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") diff --git a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift index 933d573..2d3df49 100644 --- a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift @@ -38,7 +38,7 @@ struct NativeRichEditorViewModelTests { #expect(viewModel.isShowingSlashCommands == true) #expect(viewModel.slashCommandQuery == "to") - #expect(viewModel.filteredSlashCommands.map(\.title) == ["To-do List"]) + #expect(viewModel.filteredSlashCommands.map(\.title) == ["To-do list", "Toggle block"]) } @Test func slashCommandFilteringUsesSubtitlesWhenTitlesDoNotMatch() { @@ -48,8 +48,8 @@ struct NativeRichEditorViewModelTests { viewModel.focus(blockID: block.id) let titles = viewModel.filteredSlashCommands.map(\.title) - #expect(titles.contains("Math Inline")) - #expect(titles.contains("Math Block")) + #expect(titles.contains("Math inline")) + #expect(titles.contains("Math block")) } @Test func applyingSlashCommandTransformsActiveBlockAndClearsSlashToken() { @@ -92,10 +92,10 @@ struct NativeRichEditorViewModelTests { Issue.record("Expected table block") return } - #expect(table.rows.count == 2) + expectDocmostDefaultTableShape(table) #expect(viewModel.document.blocks[0].isEditable == false) #expect(viewModel.document.proseMirrorDocument.content.first?.type == "table") - #expect(viewModel.document.proseMirrorDocument.content.first?.content?.count == 2) + #expect(viewModel.document.proseMirrorDocument.content.first?.content?.count == 3) } @Test func slashCommandInventoryIncludesMediaFileAndDiagramBlocks() { @@ -104,10 +104,10 @@ struct NativeRichEditorViewModelTests { #expect(titles.contains("Image")) #expect(titles.contains("Video")) #expect(titles.contains("Audio")) - #expect(titles.contains("PDF")) - #expect(titles.contains("File")) - #expect(titles.contains("Draw.io")) - #expect(titles.contains("Excalidraw")) + #expect(titles.contains("Embed PDF")) + #expect(titles.contains("File attachment")) + #expect(titles.contains("Draw.io (diagrams.net)")) + #expect(titles.contains("Excalidraw (Whiteboard)")) } @Test func slashCommandInventoryIncludesInlineEverydayCommands() { @@ -117,7 +117,7 @@ struct NativeRichEditorViewModelTests { #expect(titles.contains("Time")) #expect(titles.contains("Status")) #expect(titles.contains("Emoji")) - #expect(titles.contains("Math Inline")) + #expect(titles.contains("Math inline")) } @Test func mediaSlashCommandsMapToAttachmentImportKinds() { @@ -168,10 +168,13 @@ struct NativeRichEditorViewModelTests { viewModel.applySlashCommand(.mermaid) + let mermaidSeed = "flowchart LR\n A --> B" #expect(viewModel.document.blocks[0].kind == .codeBlock(language: "mermaid")) + #expect(String(viewModel.document.blocks[0].text.characters) == mermaidSeed) #expect(viewModel.document.blocks[0].isEditable == true) #expect(viewModel.document.proseMirrorDocument.content.first?.type == "codeBlock") #expect(viewModel.document.proseMirrorDocument.content.first?.attrs?["language"] == .string("mermaid")) + #expect(viewModel.document.proseMirrorDocument.content.first?.content?.first?.text == mermaidSeed) } @Test func applyingDateTimeAndEmojiSlashCommandsReplacesSlashToken() throws { @@ -220,7 +223,7 @@ struct NativeRichEditorViewModelTests { viewModel.document = NativeEditorDocument(blocks: [block]) viewModel.focus(blockID: block.id) - #expect(viewModel.filteredSlashCommands.map(\.title).contains("Math Inline")) + #expect(viewModel.filteredSlashCommands.map(\.title).contains("Math inline")) viewModel.applySlashCommand(.mathInline) @@ -445,10 +448,6 @@ struct NativeRichEditorViewModelTests { proseMirrorInlineNodes(from: viewModel).flatMap { $0.marks ?? [] } } - private func proseMirrorInlineNodes(from viewModel: NativeRichEditorViewModel) -> [ProseMirrorNode] { - viewModel.document.proseMirrorDocument.content.first?.content ?? [] - } - private func inlineSlashCommandText( command: NativeEditorCommand, slashText: String, @@ -493,3 +492,15 @@ private struct SlashCommandExpectation { let nodeType: String let label: String } + +@MainActor +private func proseMirrorInlineNodes(from viewModel: NativeRichEditorViewModel) -> [ProseMirrorNode] { + viewModel.document.proseMirrorDocument.content.first?.content ?? [] +} + +private func expectDocmostDefaultTableShape(_ table: NativeEditorTable) { + #expect(table.rows.count == 3) + #expect(table.columnCount == 3) + #expect(table.rows.first?.cells.allSatisfy(\.isHeader) == true) + #expect(table.rows.dropFirst().flatMap(\.cells).allSatisfy { $0.isHeader == false }) +} diff --git a/docmostlyTests/Networking/MultipartFormDataBodyTests.swift b/docmostlyTests/Networking/MultipartFormDataBodyTests.swift index 94dd343..b54a7de 100644 --- a/docmostlyTests/Networking/MultipartFormDataBodyTests.swift +++ b/docmostlyTests/Networking/MultipartFormDataBodyTests.swift @@ -4,7 +4,7 @@ import Testing struct MultipartFormDataBodyTests { @Test func writesFieldsAndFileIntoMultipartBody() throws { - let sourceURL = URL.temporaryDirectory.appending(path: "docmostly-upload-source.txt") + let sourceURL = temporarySourceURL() try "hello attachment".write(to: sourceURL, atomically: true, encoding: .utf8) defer { try? FileManager.default.removeItem(at: sourceURL) @@ -40,7 +40,7 @@ struct MultipartFormDataBodyTests { } @Test func rejectsHeaderControlCharacters() throws { - let sourceURL = URL.temporaryDirectory.appending(path: "docmostly-upload-source.txt") + let sourceURL = temporarySourceURL() try "hello attachment".write(to: sourceURL, atomically: true, encoding: .utf8) defer { try? FileManager.default.removeItem(at: sourceURL) @@ -94,4 +94,8 @@ struct MultipartFormDataBodyTests { ) #expect(remainingFiles.isEmpty) } + + private func temporarySourceURL() -> URL { + URL.temporaryDirectory.appending(path: "docmostly-upload-source-\(UUID().uuidString).txt") + } }