diff --git a/Tools/CRDTRuntime/src/docmostly-crdt-runtime.js b/Tools/CRDTRuntime/src/docmostly-crdt-runtime.js index aba4aba..d745dab 100644 --- a/Tools/CRDTRuntime/src/docmostly-crdt-runtime.js +++ b/Tools/CRDTRuntime/src/docmostly-crdt-runtime.js @@ -14,16 +14,8 @@ const schema = new Schema({ nodes: { doc: { content: "block+" }, text: { group: "inline" }, - paragraph: { - group: "block", - content: "inline*", - attrs: { textAlign: { default: null } } - }, - heading: { - group: "block", - content: "inline*", - attrs: { level: { default: 1 }, textAlign: { default: null } } - }, + paragraph: textBlockAttrs(), + heading: textBlockAttrs({ level: { default: 1 } }), blockquote: { group: "block", content: "block+" }, codeBlock: { group: "block", @@ -157,6 +149,19 @@ function leafBlockAttrs(attrs = {}) { return { group: "block", atom: true, attrs }; } +function textBlockAttrs(attrs = {}) { + return { + group: "block", + content: "inline*", + attrs: { + id: { default: null }, + indent: { default: 0 }, + textAlign: { default: null }, + ...attrs + } + }; +} + function inlineAtomAttrs(attrs = {}) { return { inline: true, group: "inline", atom: true, attrs }; } diff --git a/Tools/CRDTRuntime/test/docmostly-crdt-runtime.test.js b/Tools/CRDTRuntime/test/docmostly-crdt-runtime.test.js index 82debf1..3c3d899 100644 --- a/Tools/CRDTRuntime/test/docmostly-crdt-runtime.test.js +++ b/Tools/CRDTRuntime/test/docmostly-crdt-runtime.test.js @@ -17,7 +17,11 @@ const schema = new Schema({ paragraph: { group: "block", content: "inline*", - attrs: { textAlign: { default: null } } + attrs: { + id: { default: null }, + indent: { default: 0 }, + textAlign: { default: null } + } } }, marks: {} @@ -67,6 +71,45 @@ test("applies remote document update to empty native state", () => { }]); }); +test("preserves Docmost block identity and indentation in Yjs projections", () => { + const source = globalThis.docmostlyCRDT.createDocument({ + pageID: "page-1", + title: "Page", + document: paragraphDocument("Seed") + }); + const target = globalThis.docmostlyCRDT.createDocument({ + pageID: "page-1", + title: "Page", + document: paragraphDocument("Seed") + }); + const identifiedDocument = { + type: "doc", + content: [{ + type: "paragraph", + attrs: { + id: "stableanchor", + indent: 2 + }, + content: [{ type: "text", text: "Shared edit" }] + }] + }; + + source.integrateLocalChange({ + after: { + title: "Page", + document: identifiedDocument + } + }); + const [update] = source.drainLocalUpdates(); + target.applyRemoteUpdate(update); + + assert.deepEqual(target.drainDocumentSnapshots(), [{ + title: null, + document: identifiedDocument, + updatedAt: null + }]); +}); + test("does not duplicate content when syncing with server-converted ydoc", () => { const nativeDocument = globalThis.docmostlyCRDT.createDocument({ pageID: "page-1", @@ -164,15 +207,48 @@ test("merges non-overlapping edits from two offline native documents", () => { ); }); +test("two live native editors converge after exchanging concurrent updates", () => { + const baseDocument = paragraphsDocument("First", "Second"); + const serverDocument = serverYDocFromJSON(baseDocument); + const baseState = base64FromBytes(Y.encodeStateAsUpdate(serverDocument)); + const firstEditor = offlineDocument(baseState, baseDocument); + const secondEditor = offlineDocument(baseState, baseDocument); + + firstEditor.integrateLocalChange({ + after: { title: "Page", document: paragraphsDocument("First by A", "Second") } + }); + secondEditor.integrateLocalChange({ + after: { title: "Page", document: paragraphsDocument("First", "Second by B") } + }); + const firstUpdates = firstEditor.drainLocalUpdates(); + const secondUpdates = secondEditor.drainLocalUpdates(); + + for (const update of secondUpdates) { + firstEditor.applyRemoteUpdate(update); + } + for (const update of firstUpdates) { + secondEditor.applyRemoteUpdate(update); + } + + const expectedDocument = paragraphsDocument("First by A", "Second by B"); + assert.deepEqual(firstEditor.currentSnapshot().document, expectedDocument); + assert.deepEqual(secondEditor.currentSnapshot().document, expectedDocument); +}); + function paragraphDocument(text) { return paragraphsDocument(text); } function paragraphsDocument(...texts) { + const blockIDs = ["firstblockid", "secondblocki"]; return { type: "doc", - content: texts.map((text) => ({ + content: texts.map((text, index) => ({ type: "paragraph", + attrs: { + id: blockIDs[index] ?? `fallbackblock${index}`, + indent: 0 + }, content: [{ type: "text", text }] })) }; diff --git a/docmostly/App/AppState+Navigation.swift b/docmostly/App/AppState+Navigation.swift index 95dd456..56bc673 100644 --- a/docmostly/App/AppState+Navigation.swift +++ b/docmostly/App/AppState+Navigation.swift @@ -2,32 +2,25 @@ import Foundation extension AppState { func selectSidebarDestination(_ destination: SidebarDestination?) { - let resolvedDestination = destination ?? sidebarReturnDestination - sidebarReturnDestination = nil - selectedSidebarDestination = resolvedDestination + selectedSidebarDestination = destination - if case .space(let spaceID) = resolvedDestination { + if case .space(let spaceID) = destination { selectSpace(id: spaceID) } } - func selectSidebarUtilityDestination( - _ destination: SidebarDestination, - returningTo returnDestination: SidebarDestination? = nil - ) { + func selectSidebarUtilityDestination(_ destination: SidebarDestination) { if case .space(let spaceID) = destination { selectSpace(id: spaceID) return } - sidebarReturnDestination = returnDestination selectedSidebarDestination = destination selectedPageID = nil selectedCommentID = nil } func selectSpace(id spaceID: String, clearsPage: Bool = true) { - sidebarReturnDestination = nil selectedSpaceID = spaceID selectedSidebarDestination = .space(spaceID) rememberSelectedSpace(id: spaceID) @@ -77,7 +70,6 @@ extension AppState { } func resetNavigationSelection() { - sidebarReturnDestination = nil selectedSidebarDestination = nil selectedSpaceID = nil selectedPageID = nil diff --git a/docmostly/App/AppState.swift b/docmostly/App/AppState.swift index 409ecf4..11c0054 100644 --- a/docmostly/App/AppState.swift +++ b/docmostly/App/AppState.swift @@ -36,7 +36,6 @@ final class AppState { @ObservationIgnored private(set) var apiClient: DocmostAPIClient? @ObservationIgnored private var restoreTask: Task? @ObservationIgnored private var spacesLoadTask: Task? - @ObservationIgnored var sidebarReturnDestination: SidebarDestination? @ObservationIgnored private var pendingCacheWrites: [CacheWriteOperation] = [] @ObservationIgnored private var cacheWriteTask: Task? @ObservationIgnored var offlineReplayTask: Task? diff --git a/docmostly/DocmostlyCRDTRuntime.js b/docmostly/DocmostlyCRDTRuntime.js index edd042f..04c251a 100644 --- a/docmostly/DocmostlyCRDTRuntime.js +++ b/docmostly/DocmostlyCRDTRuntime.js @@ -13102,16 +13102,8 @@ ${err.toString()}`); nodes: { doc: { content: "block+" }, text: { group: "inline" }, - paragraph: { - group: "block", - content: "inline*", - attrs: { textAlign: { default: null } } - }, - heading: { - group: "block", - content: "inline*", - attrs: { level: { default: 1 }, textAlign: { default: null } } - }, + paragraph: textBlockAttrs(), + heading: textBlockAttrs({ level: { default: 1 } }), blockquote: { group: "block", content: "block+" }, codeBlock: { group: "block", @@ -13243,6 +13235,18 @@ ${err.toString()}`); function leafBlockAttrs(attrs = {}) { return { group: "block", atom: true, attrs }; } + function textBlockAttrs(attrs = {}) { + return { + group: "block", + content: "inline*", + attrs: { + id: { default: null }, + indent: { default: 0 }, + textAlign: { default: null }, + ...attrs + } + }; + } function inlineAtomAttrs(attrs = {}) { return { inline: true, group: "inline", atom: true, attrs }; } diff --git a/docmostly/Features/Editor/DocumentSession.swift b/docmostly/Features/Editor/DocumentSession.swift index 336d4d0..2fb2ff3 100644 --- a/docmostly/Features/Editor/DocumentSession.swift +++ b/docmostly/Features/Editor/DocumentSession.swift @@ -12,6 +12,12 @@ final class DocumentSession { @ObservationIgnored private var snapshotContinuations: [ UUID: AsyncStream.Continuation ] = [:] + @ObservationIgnored private var localChangeBarriers: [ + UUID: @MainActor @Sendable () async throws -> Bool + ] = [:] + @ObservationIgnored private var remoteProjectionHandlers: [ + UUID: @MainActor @Sendable (NativeEditorCRDTDocumentSnapshot) -> Bool + ] = [:] @ObservationIgnored private var isCompacting = false @ObservationIgnored private var retainedDraftTitle: String? @ObservationIgnored private var retainedDraft: ProseMirrorDocument? @@ -92,6 +98,22 @@ final class DocumentSession { retainedDraft = nil } + func attachEditor( + sourceID: UUID, + localChangeBarrier: @escaping @MainActor @Sendable () async throws -> Bool, + remoteProjectionHandler: @escaping @MainActor @Sendable ( + NativeEditorCRDTDocumentSnapshot + ) -> Bool + ) { + localChangeBarriers[sourceID] = localChangeBarrier + remoteProjectionHandlers[sourceID] = remoteProjectionHandler + } + + func detachEditor(sourceID: UUID) { + localChangeBarriers[sourceID] = nil + remoteProjectionHandlers[sourceID] = nil + } + func hasPendingSynchronization() async throws -> Bool { if retainedDraft != nil { return true @@ -138,18 +160,23 @@ private extension DocumentSession { } func commitRemoteUpdate(_ update: Data) async throws { + try await waitForAttachedEditorsToIntegrateLocalChanges() try await kernel.validate(update) let committed = try await localPeer.append(update, origin: .remote, key: key) - guard committed.wasInserted else { return } + try await waitForAttachedEditorsToIntegrateLocalChanges() var snapshot = try await kernel.apply(update) - await indexer.documentUpdateCommitted(committed) + if committed.wasInserted { + await indexer.documentUpdateCommitted(committed) + } if retainedDraft != nil { snapshot = try await promoteRetainedDraft() ?? snapshot } if let snapshot { - publish(snapshot) + publish(snapshot, notifyingAttachedEditors: true) + } + if committed.wasInserted { + try await compactIfNeeded() } - try await compactIfNeeded() } func pendingLocalUpdatePayloads() async throws -> [Data] { @@ -256,8 +283,27 @@ private extension DocumentSession { return try await kernel.snapshot() } - func publish(_ snapshot: NativeEditorCRDTDocumentSnapshot) { + func waitForAttachedEditorsToIntegrateLocalChanges() async throws { + let barriers = localChangeBarriers + for (sourceID, barrier) in barriers { + guard try await barrier() else { + detachEditor(sourceID: sourceID) + continue + } + } + } + + func publish( + _ snapshot: NativeEditorCRDTDocumentSnapshot, + notifyingAttachedEditors: Bool = false + ) { initialSnapshot = snapshot + if notifyingAttachedEditors { + let handlers = remoteProjectionHandlers + for (sourceID, handler) in handlers where handler(snapshot) == false { + detachEditor(sourceID: sourceID) + } + } for continuation in snapshotContinuations.values { continuation.yield(snapshot) } diff --git a/docmostly/Features/Editor/NativeEditorBlockRow.swift b/docmostly/Features/Editor/NativeEditorBlockRow.swift index 706fc78..7361081 100644 --- a/docmostly/Features/Editor/NativeEditorBlockRow.swift +++ b/docmostly/Features/Editor/NativeEditorBlockRow.swift @@ -2,7 +2,8 @@ import SwiftUI struct NativeEditorBlockRow: View { @Binding var block: NativeEditorBlock - let focusedField: FocusState.Binding + let isActive: Bool + let focusRequestID: UUID? let isSelected: Bool let isShowingControls: Bool let isReadOnly: Bool @@ -20,6 +21,7 @@ struct NativeEditorBlockRow: View { let presenceScope: [NativeEditorRemotePresenceScope] let presenceBlockIndex: Int let focusBlock: () -> Void + let textInputFocusChanged: (Bool) -> Void let moveBefore: (UUID) -> Void let splitBlock: (Range) -> Bool let insertHardBreak: (Range) -> Bool @@ -63,7 +65,9 @@ struct NativeEditorBlockRow: View { NativeEditorBlockTextSurface(kind: block.kind) { NativeEditorTextInputView( block: $block, - isFocused: blockFocusBinding, + isFocused: isActive, + focusRequestID: focusRequestID, + focusChanged: textInputFocusChanged, accessibilityLabel: block.kind.accessibilityLabel, actions: NativeEditorTextInputActions( handleReturn: handleReturn, @@ -201,12 +205,7 @@ struct NativeEditorBlockRow: View { } private var hasVisiblePrefix: Bool { - switch block.kind { - case .bulletListItem, .orderedListItem, .taskListItem, .unsupported: - true - default: - false - } + NativeEditorBlockRowPolicy.hasVisiblePrefix(kind: block.kind) } private var blockIndentPadding: CGFloat { @@ -228,18 +227,6 @@ struct NativeEditorBlockRow: View { showsControls ? richBlockActions : nil } - private var blockFocusBinding: Binding { - Binding { - focusedField.wrappedValue == .block(block.id) - } set: { shouldFocus in - if shouldFocus { - focusedField.wrappedValue = .block(block.id) - } else if focusedField.wrappedValue == .block(block.id) { - focusedField.wrappedValue = nil - } - } - } - private func handleReturn(_ selection: Range) -> Bool { switch NativeEditorReturnKeyBehavior.resolve( kind: block.kind, diff --git a/docmostly/Features/Editor/NativeEditorBlockRowPolicy.swift b/docmostly/Features/Editor/NativeEditorBlockRowPolicy.swift index fe2312e..7dfa785 100644 --- a/docmostly/Features/Editor/NativeEditorBlockRowPolicy.swift +++ b/docmostly/Features/Editor/NativeEditorBlockRowPolicy.swift @@ -8,4 +8,13 @@ nonisolated enum NativeEditorBlockRowPolicy { static func allowsTaskToggle(isReadOnly: Bool) -> Bool { isReadOnly == false } + + static func hasVisiblePrefix(kind: NativeEditorBlockKind) -> Bool { + switch kind { + case .bulletListItem, .orderedListItem, .taskListItem, .unsupported: + true + default: + false + } + } } diff --git a/docmostly/Features/Editor/NativeEditorBodyView.swift b/docmostly/Features/Editor/NativeEditorBodyView.swift index b00455a..66dd6f8 100644 --- a/docmostly/Features/Editor/NativeEditorBodyView.swift +++ b/docmostly/Features/Editor/NativeEditorBodyView.swift @@ -1,7 +1,7 @@ import SwiftUI struct NativeEditorBodyView: View { - @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion + @State private var textInputFocusRequest: NativeEditorTextInputFocusRequest? @Bindable var viewModel: NativeRichEditorViewModel let focusedField: FocusState.Binding var isAuthoringEnabled = true @@ -21,10 +21,9 @@ struct NativeEditorBodyView: View { LazyVStack(alignment: .leading, spacing: 6) { if showsTitle { HStack(alignment: .firstTextBaseline) { - if isEditingTitle, let pickPageEmoji { + if let pickPageEmoji { NativeEditorPageTitleIconButton(icon: viewModel.icon, action: pickPageEmoji) .disabled(authoringIsAvailable == false) - .transition(NativeEditorPageTitleIconTransition()) } TextField("Page title", text: $viewModel.title, axis: .vertical) @@ -37,7 +36,6 @@ struct NativeEditorBodyView: View { .disabled(authoringIsAvailable == false) .accessibilityLabel("Page title") } - .animation(titleEditingAnimation, value: isEditingTitle) if let creatorName = viewModel.creator?.name, creatorName.isEmpty == false { NativeEditorBylineView(authorName: creatorName) @@ -60,7 +58,10 @@ struct NativeEditorBodyView: View { VStack(alignment: .leading, spacing: 6) { NativeEditorBlockRow( block: $block, - focusedField: focusedField, + isActive: viewModel.activeBlockID == block.id, + focusRequestID: textInputFocusRequest?.blockID == block.id + ? textInputFocusRequest?.id + : nil, isSelected: viewModel.selectedBlockID == block.id, isShowingControls: viewModel.visibleBlockControlsID == block.id, isReadOnly: authoringIsAvailable == false, @@ -75,10 +76,12 @@ struct NativeEditorBodyView: View { insertBelow: { guard authoringIsAvailable else { return } viewModel.insertBlock(after: block.id) + requestActiveBlockFocus() }, delete: { guard authoringIsAvailable else { return } - viewModel.deleteBlock(block.id) + guard let destinationBlockID = viewModel.deleteBlock(block.id) else { return } + requestBlockFocus(destinationBlockID) }, tableActions: authoringIsAvailable ? tableEditingActions : nil, richBlockActions: authoringIsAvailable ? richBlockEditingActions : nil, @@ -93,6 +96,14 @@ struct NativeEditorBodyView: View { guard authoringIsAvailable else { return } viewModel.focus(blockID: block.id) }, + textInputFocusChanged: { isFocused in + if isFocused { + guard authoringIsAvailable else { return } + viewModel.textInputDidBeginEditing(blockID: block.id) + } else { + viewModel.textInputDidEndEditing(blockID: block.id) + } + }, moveBefore: { movedBlockID in guard authoringIsAvailable else { return } viewModel.moveBlock(movedBlockID, before: block.id) @@ -136,7 +147,7 @@ struct NativeEditorBodyView: View { .id(block.id) if authoringIsAvailable, viewModel.selectedBlockID == block.id { - NativeEditorBlockSelectionBar(delete: viewModel.deleteSelectedBlock) + NativeEditorBlockSelectionBar(delete: deleteSelectedBlock) } if authoringIsAvailable, viewModel.activeBlockID == block.id, viewModel.isShowingSlashCommands { @@ -153,7 +164,7 @@ struct NativeEditorBodyView: View { } if authoringIsAvailable { - Button("Add Block", systemImage: "plus", action: viewModel.appendBlock) + Button("Add Block", systemImage: "plus", action: appendBlock) .buttonStyle(.plain) .foregroundStyle(.secondary) } @@ -162,8 +173,13 @@ struct NativeEditorBodyView: View { viewModel.handleTitleChanged() } .onChange(of: viewModel.activeBlockID) { _, blockID in - guard let blockID else { return } - focusedField.wrappedValue = .block(blockID) + guard let blockID else { + textInputFocusRequest = nil + return + } + guard viewModel.focusedTextInputBlockID != blockID else { return } + guard textInputFocusRequest?.blockID != blockID else { return } + enqueueBlockFocusRequest(blockID) } } @@ -171,14 +187,6 @@ struct NativeEditorBodyView: View { isAuthoringEnabled && viewModel.canEdit && viewModel.isResolvingConflict == false } - private var isEditingTitle: Bool { - focusedField.wrappedValue == .title - } - - private var titleEditingAnimation: Animation? { - accessibilityReduceMotion ? nil : .easeInOut(duration: 0.22) - } - private var activePresenceProjection: NativeEditorRemotePresenceProjection { presenceProjection ?? viewModel.remotePresenceProjection } @@ -197,28 +205,66 @@ struct NativeEditorBodyView: View { private func advanceFromTitle() { guard authoringIsAvailable else { return } if let firstEditableBlock = viewModel.document.blocks.first(where: \.isEditable) { - viewModel.focus(blockID: firstEditableBlock.id) + requestBlockFocus(firstEditableBlock.id) } else { - viewModel.appendBlock() + appendBlock() } } private func requestBlockFocus(_ blockID: UUID) { - focusedField.wrappedValue = .block(blockID) + guard + authoringIsAvailable, + viewModel.document.blocks.contains(where: { $0.id == blockID && $0.isEditable }) + else { + return + } + + viewModel.focus(blockID: blockID) + enqueueBlockFocusRequest(blockID) + } + + private func enqueueBlockFocusRequest(_ blockID: UUID) { + textInputFocusRequest = NativeEditorTextInputFocusRequest(blockID: blockID) Task { @MainActor in await Task.yield() guard authoringIsAvailable, + viewModel.activeBlockID == blockID, viewModel.document.blocks.contains(where: { $0.id == blockID && $0.isEditable }) else { return } - viewModel.focus(blockID: blockID) - focusedField.wrappedValue = .block(blockID) + textInputFocusRequest = NativeEditorTextInputFocusRequest(blockID: blockID) + + try? await Task.sleep(for: .milliseconds(250)) + guard + authoringIsAvailable, + viewModel.activeBlockID == blockID, + viewModel.focusedTextInputBlockID != blockID, + viewModel.document.blocks.contains(where: { $0.id == blockID && $0.isEditable }) + else { + return + } + textInputFocusRequest = NativeEditorTextInputFocusRequest(blockID: blockID) } } + private func requestActiveBlockFocus() { + guard let activeBlockID = viewModel.activeBlockID else { return } + requestBlockFocus(activeBlockID) + } + + private func appendBlock() { + viewModel.appendBlock() + requestActiveBlockFocus() + } + + private func deleteSelectedBlock() { + guard let destinationBlockID = viewModel.deleteSelectedBlock() else { return } + requestBlockFocus(destinationBlockID) + } + private var tableEditingActions: NativeEditorTableEditingActions { NativeEditorTableEditingActions( updateCell: { blockID, rowIndex, columnIndex, text in @@ -259,3 +305,8 @@ struct NativeEditorBodyView: View { ) } } + +private struct NativeEditorTextInputFocusRequest { + let id = UUID() + let blockID: UUID +} diff --git a/docmostly/Features/Editor/NativeEditorDebugPreviewView.swift b/docmostly/Features/Editor/NativeEditorDebugPreviewView.swift index 7fecb24..d32d9ea 100644 --- a/docmostly/Features/Editor/NativeEditorDebugPreviewView.swift +++ b/docmostly/Features/Editor/NativeEditorDebugPreviewView.swift @@ -48,6 +48,7 @@ struct NativeEditorDebugPreviewView: View { case .block(let blockID): viewModel.focus(blockID: blockID) case nil: + guard viewModel.isTitleFocused else { return } viewModel.clearFocus() } } diff --git a/docmostly/Features/Editor/NativeEditorDocument+CRDTProjection.swift b/docmostly/Features/Editor/NativeEditorDocument+CRDTProjection.swift new file mode 100644 index 0000000..adb6b44 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorDocument+CRDTProjection.swift @@ -0,0 +1,215 @@ +import Foundation +import SwiftUI + +nonisolated extension NativeEditorDocument { + func reconcilingEditorState(from currentDocument: Self) -> Self { + let matches = Self.crdtProjectionMatches( + currentBlocks: currentDocument.blocks, + projectedBlocks: blocks + ) + let reconciledBlocks = blocks.enumerated().map { projectedIndex, projectedBlock in + guard let currentIndex = matches[projectedIndex] else { + return projectedBlock + } + + return Self.reconciledProjectedBlock( + projectedBlock, + currentBlock: currentDocument.blocks[currentIndex] + ) + } + return Self(blocks: reconciledBlocks) + } + + private static func crdtProjectionMatches( + currentBlocks: [NativeEditorBlock], + projectedBlocks: [NativeEditorBlock] + ) -> [Int: Int] { + var matches: [Int: Int] = [:] + var matchedCurrentIndices: Set = [] + + let currentIndicesByNodeID = Dictionary( + grouping: currentBlocks.indices.compactMap { index in + currentBlocks[index].crdtNodeID.map { ($0, index) } + }, + by: { $0.0 } + ) + for projectedIndex in projectedBlocks.indices { + guard + let nodeID = projectedBlocks[projectedIndex].crdtNodeID, + let candidates = currentIndicesByNodeID[nodeID], + let currentIndex = candidates.lazy.map(\.1).first(where: { + matchedCurrentIndices.contains($0) == false + }) + else { + continue + } + + matches[projectedIndex] = currentIndex + matchedCurrentIndices.insert(currentIndex) + } + + let currentIndicesByNode = Dictionary( + grouping: currentBlocks.indices.filter { + matchedCurrentIndices.contains($0) == false + }, + by: { currentBlocks[$0].crdtProjectionNode } + ) + for projectedIndex in projectedBlocks.indices where matches[projectedIndex] == nil { + let projectedNode = projectedBlocks[projectedIndex].crdtProjectionNode + guard + let candidates = currentIndicesByNode[projectedNode], + let currentIndex = candidates.first(where: { + matchedCurrentIndices.contains($0) == false + }) + else { + continue + } + + matches[projectedIndex] = currentIndex + matchedCurrentIndices.insert(currentIndex) + } + + let unmatchedProjectedIndices = projectedBlocks.indices.filter { matches[$0] == nil } + let unmatchedCurrentIndices = currentBlocks.indices.filter { + matchedCurrentIndices.contains($0) == false + } + guard unmatchedProjectedIndices.count == unmatchedCurrentIndices.count else { + return matches + } + + for (projectedIndex, currentIndex) in zip(unmatchedProjectedIndices, unmatchedCurrentIndices) { + guard + projectedBlocks[projectedIndex].crdtProjectionNode.type == + currentBlocks[currentIndex].crdtProjectionNode.type + else { + continue + } + + matches[projectedIndex] = currentIndex + } + return matches + } + + private static func reconciledProjectedBlock( + _ projectedBlock: NativeEditorBlock, + currentBlock: NativeEditorBlock + ) -> NativeEditorBlock { + NativeEditorBlock( + id: currentBlock.id, + kind: projectedBlock.kind, + text: projectedBlock.text, + alignment: projectedBlock.alignment, + indentLevel: projectedBlock.indentLevel, + selection: projectedBlock.text.crdtSelection( + preserving: currentBlock.selection, + from: currentBlock.text + ), + inlineContent: projectedBlock.inlineContent, + rawNode: projectedBlock.rawNode + ) + } +} + +nonisolated private extension NativeEditorBlock { + var crdtProjectionNode: ProseMirrorNode { + NativeEditorDocument.node(from: self) + } + + var crdtNodeID: String? { + crdtProjectionNode.firstCRDTNodeID + } +} + +nonisolated private extension ProseMirrorNode { + var firstCRDTNodeID: String? { + if let nodeID = attrs?["id"]?.stringValue { + return nodeID + } + + for child in content ?? [] { + if let nodeID = child.firstCRDTNodeID { + return nodeID + } + } + return nil + } +} + +nonisolated private extension AttributedString { + func crdtSelection( + preserving selection: AttributedTextSelection, + from previousText: AttributedString + ) -> AttributedTextSelection { + switch selection.indices(in: previousText) { + case .insertionPoint(let index): + let previousOffset = previousText.characters.distance( + from: previousText.startIndex, + to: index + ) + let mappedOffset = crdtMappedOffset(previousOffset, from: previousText) + let insertionPoint = characters.index(startIndex, offsetBy: mappedOffset) + return AttributedTextSelection(insertionPoint: insertionPoint) + case .ranges(let ranges): + guard let range = ranges.ranges.first else { + return AttributedTextSelection() + } + + let previousLowerBound = previousText.characters.distance( + from: previousText.startIndex, + to: range.lowerBound + ) + let previousUpperBound = previousText.characters.distance( + from: previousText.startIndex, + to: range.upperBound + ) + let lowerBound = crdtMappedOffset(previousLowerBound, from: previousText) + let upperBound = crdtMappedOffset(previousUpperBound, from: previousText) + let requestedRange = min(lowerBound, upperBound).. Int { + let previousCharacters = Array(previousText.characters) + let projectedCharacters = Array(characters) + let clampedOffset = min(max(requestedOffset, 0), previousCharacters.count) + + var commonPrefixCount = 0 + while + commonPrefixCount < previousCharacters.count, + commonPrefixCount < projectedCharacters.count, + previousCharacters[commonPrefixCount] == projectedCharacters[commonPrefixCount] { + commonPrefixCount += 1 + } + + var commonSuffixCount = 0 + while + commonSuffixCount < previousCharacters.count - commonPrefixCount, + commonSuffixCount < projectedCharacters.count - commonPrefixCount, + previousCharacters[previousCharacters.count - commonSuffixCount - 1] == + projectedCharacters[projectedCharacters.count - commonSuffixCount - 1] { + commonSuffixCount += 1 + } + + let previousChangeEnd = previousCharacters.count - commonSuffixCount + let projectedChangeEnd = projectedCharacters.count - commonSuffixCount + let mappedOffset: Int + if clampedOffset <= commonPrefixCount { + mappedOffset = clampedOffset + } else if clampedOffset >= previousChangeEnd { + mappedOffset = clampedOffset + projectedChangeEnd - previousChangeEnd + } else { + mappedOffset = commonPrefixCount + min( + clampedOffset - commonPrefixCount, + projectedChangeEnd - commonPrefixCount + ) + } + return min(max(mappedOffset, 0), projectedCharacters.count) + } +} diff --git a/docmostly/Features/Editor/NativeEditorNestedDocumentView.swift b/docmostly/Features/Editor/NativeEditorNestedDocumentView.swift index 767ae36..c68d2dc 100644 --- a/docmostly/Features/Editor/NativeEditorNestedDocumentView.swift +++ b/docmostly/Features/Editor/NativeEditorNestedDocumentView.swift @@ -88,12 +88,21 @@ struct NativeEditorNestedDocumentPreview: View { VStack(alignment: .leading, spacing: 6) { ForEach(document.blocks) { block in - NativeEditorRichBlockPreviewView( - block: block, - pageID: pageID, - spaceID: spaceID, - serverURLString: serverURLString - ) + HStack(alignment: .top, spacing: 8) { + if NativeEditorBlockRowPolicy.hasVisiblePrefix(kind: block.kind) { + NativeEditorBlockPrefix(block: .constant(block), allowsTaskToggle: false) + .frame(width: 24, alignment: .center) + } + + NativeEditorRichBlockPreviewView( + block: block, + pageID: pageID, + spaceID: spaceID, + serverURLString: serverURLString + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.leading, CGFloat(block.indentLevel) * 22) } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/docmostly/Features/Editor/NativeEditorPageTitleIconTransition.swift b/docmostly/Features/Editor/NativeEditorPageTitleIconTransition.swift deleted file mode 100644 index 8be8146..0000000 --- a/docmostly/Features/Editor/NativeEditorPageTitleIconTransition.swift +++ /dev/null @@ -1,9 +0,0 @@ -import SwiftUI - -struct NativeEditorPageTitleIconTransition: Transition { - func body(content: Content, phase: TransitionPhase) -> some View { - content - .blur(radius: phase.isIdentity ? 0 : 8) - .opacity(phase.isIdentity ? 1 : 0) - } -} diff --git a/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift b/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift index 1ecf671..2ea1fab 100644 --- a/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift +++ b/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift @@ -168,9 +168,12 @@ struct NativeEditorRichBlockPreviewView: View { case .unsupported: NativeEditorUnsupportedBlockView(block: block) case .paragraph, .heading, .bulletListItem, .orderedListItem, .taskListItem, .blockquote, .codeBlock: - Text(NativeEditorPreviewTextFormatter.text(block.text, for: block.kind)) - .font(block.kind.editorFont) - .frame(maxWidth: .infinity, alignment: .leading) + NativeEditorBlockTextSurface(kind: block.kind) { + Text(NativeEditorPreviewTextFormatter.text(block.text, for: block.kind)) + .font(block.kind.editorFont) + .multilineTextAlignment(block.alignment.swiftUITextAlignment) + .frame(maxWidth: .infinity, alignment: block.alignment.swiftUIFrameAlignment) + } } } diff --git a/docmostly/Features/Editor/NativeEditorTableGridView.swift b/docmostly/Features/Editor/NativeEditorTableGridView.swift index 91316e4..2f7ce74 100644 --- a/docmostly/Features/Editor/NativeEditorTableGridView.swift +++ b/docmostly/Features/Editor/NativeEditorTableGridView.swift @@ -10,16 +10,18 @@ struct NativeEditorTableReadOnlyGrid: View { if table.rows.isEmpty || table.columnCount == 0 { NativeEditorEmptyTableView() } else { + let columnWidths = (0.. CGSize { + let widths = resolvedColumnWidths(for: subviews.count) + let rowHeight = zip(subviews, widths).reduce(minimumHeight) { height, element in + let (subview, width) = element + return max( + height, + subview.sizeThatFits(.init(width: width, height: nil)).height + ) + } + + return CGSize(width: widths.reduce(0, +), height: rowHeight) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + let widths = resolvedColumnWidths(for: subviews.count) + var horizontalOffset = bounds.minX + + for (subview, width) in zip(subviews, widths) { + subview.place( + at: CGPoint(x: horizontalOffset, y: bounds.minY), + anchor: .topLeading, + proposal: .init(width: width, height: bounds.height) + ) + horizontalOffset += width + } + } + + private func resolvedColumnWidths(for subviewCount: Int) -> [CGFloat] { + if columnWidths.count >= subviewCount { + return Array(columnWidths.prefix(subviewCount)) + } + + return columnWidths + Array( + repeating: NativeEditorTableLayout.minimumColumnWidth, + count: subviewCount - columnWidths.count + ) + } +} diff --git a/docmostly/Features/Editor/NativeEditorTextAlignment.swift b/docmostly/Features/Editor/NativeEditorTextAlignment.swift index fd15a2d..32fd36f 100644 --- a/docmostly/Features/Editor/NativeEditorTextAlignment.swift +++ b/docmostly/Features/Editor/NativeEditorTextAlignment.swift @@ -1,4 +1,4 @@ -import Foundation +import SwiftUI nonisolated enum NativeEditorTextAlignment: String, Equatable, Sendable { case left @@ -14,4 +14,26 @@ nonisolated enum NativeEditorTextAlignment: String, Equatable, Sendable { var proseMirrorValue: ProseMirrorJSONValue? { self == .left ? nil : .string(rawValue) } + + var swiftUITextAlignment: TextAlignment { + switch self { + case .left, .justify: + .leading + case .center: + .center + case .right: + .trailing + } + } + + var swiftUIFrameAlignment: Alignment { + switch self { + case .left, .justify: + .leading + case .center: + .center + case .right: + .trailing + } + } } diff --git a/docmostly/Features/Editor/NativeEditorTextInputView+iOS.swift b/docmostly/Features/Editor/NativeEditorTextInputView+iOS.swift index 761fbc1..2d5ce05 100644 --- a/docmostly/Features/Editor/NativeEditorTextInputView+iOS.swift +++ b/docmostly/Features/Editor/NativeEditorTextInputView+iOS.swift @@ -4,8 +4,10 @@ import UIKit struct NativeEditorTextInputView: UIViewRepresentable { @Binding var block: NativeEditorBlock - @Binding var isFocused: Bool + let isFocused: Bool + let focusRequestID: UUID? + let focusChanged: (Bool) -> Void let accessibilityLabel: String let actions: NativeEditorTextInputActions var remotePresenceSegments: [NativeEditorRemotePresenceSegment] = [] @@ -46,6 +48,18 @@ struct NativeEditorTextInputView: UIViewRepresentable { textView.updateRemotePresence(remotePresenceSegments) } + static func dismantleUIView( + _ textView: NativeEditorUITextView, + coordinator: NativeEditorTextInputCoordinator + ) { + textView.requestsFirstResponder = false + if textView.isFirstResponder { + textView.resignFirstResponder() + } + coordinator.parent.focusChanged(false) + textView.delegate = nil + } + func sizeThatFits( _ proposal: ProposedViewSize, uiView: NativeEditorUITextView, @@ -68,6 +82,7 @@ final class NativeEditorTextInputCoordinator: NSObject, UITextViewDelegate { private var isApplyingSource = false private var pendingTextDelta: NativeEditorTextDelta? private var pendingSelectionCorrection: Range? + private var handledFocusRequestID: UUID? private var bindingEchoReconciler = NativeEditorTextBindingEchoReconciler() private var focusBindingEchoReconciler = NativeEditorFocusBindingEchoReconciler() @@ -158,6 +173,14 @@ final class NativeEditorTextInputCoordinator: NSObject, UITextViewDelegate { } func updateFocus(_ textView: NativeEditorUITextView) { + if let focusRequestID = parent.focusRequestID, + focusRequestID != handledFocusRequestID, + parent.isFocused { + handledFocusRequestID = focusRequestID + textView.requestsFirstResponder = true + textView.requestFirstResponderIfPossible() + } + switch focusBindingEchoReconciler.disposition( for: parent.isFocused, platformIsFocused: textView.isFirstResponder @@ -186,12 +209,12 @@ final class NativeEditorTextInputCoordinator: NSObject, UITextViewDelegate { func textViewDidBeginEditing(_ textView: UITextView) { focusBindingEchoReconciler.recordLocalActivation() - parent.isFocused = true + parent.focusChanged(true) } func textViewDidEndEditing(_ textView: UITextView) { focusBindingEchoReconciler.recordLocalDeactivation() - parent.isFocused = false + parent.focusChanged(false) } func textView( diff --git a/docmostly/Features/Editor/NativeEditorTextInputView+macOS.swift b/docmostly/Features/Editor/NativeEditorTextInputView+macOS.swift index 599da0d..47a0461 100644 --- a/docmostly/Features/Editor/NativeEditorTextInputView+macOS.swift +++ b/docmostly/Features/Editor/NativeEditorTextInputView+macOS.swift @@ -4,8 +4,10 @@ import SwiftUI struct NativeEditorTextInputView: NSViewRepresentable { @Binding var block: NativeEditorBlock - @Binding var isFocused: Bool + let isFocused: Bool + let focusRequestID: UUID? + let focusChanged: (Bool) -> Void let accessibilityLabel: String let actions: NativeEditorTextInputActions var remotePresenceSegments: [NativeEditorRemotePresenceSegment] = [] @@ -30,6 +32,7 @@ struct NativeEditorTextInputView: NSViewRepresentable { textView.textContainer?.heightTracksTextView = false textView.setAccessibilityLabel(accessibilityLabel) context.coordinator.applySource(to: textView) + context.coordinator.updateFocus(textView) textView.updateRemotePresence(remotePresenceSegments) return textView } @@ -43,6 +46,18 @@ struct NativeEditorTextInputView: NSViewRepresentable { textView.updateRemotePresence(remotePresenceSegments) } + static func dismantleNSView( + _ textView: NativeEditorNSTextView, + coordinator: NativeEditorTextInputCoordinator + ) { + textView.requestsFirstResponder = false + if textView.window?.firstResponder === textView { + textView.window?.makeFirstResponder(nil) + } + coordinator.parent.focusChanged(false) + textView.delegate = nil + } + func sizeThatFits( _ proposal: ProposedViewSize, nsView: NativeEditorNSTextView, @@ -75,6 +90,7 @@ final class NativeEditorTextInputCoordinator: NSObject, NSTextViewDelegate { private var isApplyingSource = false private var pendingTextDelta: NativeEditorTextDelta? private var pendingSelectionCorrection: Range? + private var handledFocusRequestID: UUID? private var bindingEchoReconciler = NativeEditorTextBindingEchoReconciler() private var focusBindingEchoReconciler = NativeEditorFocusBindingEchoReconciler() @@ -166,33 +182,6 @@ final class NativeEditorTextInputCoordinator: NSObject, NSTextViewDelegate { isApplyingSource = false } - func updateFocus(_ textView: NativeEditorNSTextView) { - switch focusBindingEchoReconciler.disposition( - for: parent.isFocused, - platformIsFocused: textView.window?.firstResponder === textView - ) { - case .activate: - textView.requestsFirstResponder = true - textView.requestFirstResponderIfPossible() - case .preserveLocalActivation: - textView.requestsFirstResponder = true - case .deactivate: - textView.requestsFirstResponder = false - guard textView.window?.firstResponder === textView else { return } - textView.window?.makeFirstResponder(nil) - } - } - - func textDidBeginEditing(_ notification: Notification) { - focusBindingEchoReconciler.recordLocalActivation() - parent.isFocused = true - } - - func textDidEndEditing(_ notification: Notification) { - focusBindingEchoReconciler.recordLocalDeactivation() - parent.isFocused = false - } - func textDidChange(_ notification: Notification) { guard isApplyingSource == false, @@ -506,6 +495,43 @@ final class NativeEditorTextInputCoordinator: NSObject, NSTextViewDelegate { } } +extension NativeEditorTextInputCoordinator { + func updateFocus(_ textView: NativeEditorNSTextView) { + if let focusRequestID = parent.focusRequestID, + focusRequestID != handledFocusRequestID, + parent.isFocused { + handledFocusRequestID = focusRequestID + textView.requestsFirstResponder = true + textView.requestFirstResponderIfPossible() + } + + switch focusBindingEchoReconciler.disposition( + for: parent.isFocused, + platformIsFocused: textView.window?.firstResponder === textView + ) { + case .activate: + textView.requestsFirstResponder = true + textView.requestFirstResponderIfPossible() + case .preserveLocalActivation: + textView.requestsFirstResponder = true + case .deactivate: + textView.requestsFirstResponder = false + guard textView.window?.firstResponder === textView else { return } + textView.window?.makeFirstResponder(nil) + } + } + + func textDidBeginEditing(_ notification: Notification) { + focusBindingEchoReconciler.recordLocalActivation() + parent.focusChanged(true) + } + + func textDidEndEditing(_ notification: Notification) { + focusBindingEchoReconciler.recordLocalDeactivation() + parent.focusChanged(false) + } +} + private extension NativeEditorBlockKind { var isHeading: Bool { if case .heading = self { diff --git a/docmostly/Features/Editor/NativeEditorToolbar.swift b/docmostly/Features/Editor/NativeEditorToolbar.swift index 6fc35c7..a8cd6cd 100644 --- a/docmostly/Features/Editor/NativeEditorToolbar.swift +++ b/docmostly/Features/Editor/NativeEditorToolbar.swift @@ -36,37 +36,30 @@ struct NativeEditorToolbar: View { } var body: some View { - VStack(spacing: 10) { - if isShowingSearchReplace { - NativeEditorToolbarSurface { - NativeEditorSearchReplaceBar(viewModel: viewModel) - } - } - - HStack(spacing: NativeEditorToolbarMetrics.groupSpacing) { - ScrollView(.horizontal) { - NativeEditorToolbarContent( - viewModel: viewModel, - isUploadingAttachment: isUploadingAttachment, - importAttachment: importAttachment, - applyCommand: applyCommand, - isShowingLinkPrompt: $isShowingLinkPrompt, - isShowingSearchReplace: $isShowingSearchReplace, - isShowingStatusPrompt: $isShowingStatusPrompt, - isShowingMathPrompt: $isShowingMathPrompt, - showMentionPicker: showMentionPicker, - showInlineCommentComposer: showInlineCommentComposer - ) - .padding(.leading, 10) - .padding(.vertical, 4) + GlassEffectContainer(spacing: NativeEditorToolbarMetrics.groupSpacing) { + VStack(spacing: NativeEditorToolbarMetrics.groupSpacing) { + if isShowingSearchReplace { + NativeEditorToolbarSurface { + NativeEditorSearchReplaceBar(viewModel: viewModel) + } } - .scrollIndicators(.hidden) - .frame(maxWidth: .infinity, alignment: .leading) - NativeEditorKeyboardDismissToolbarButton(dismissKeyboard: dismissKeyboard) - .padding(.trailing, 10) + NativeEditorToolbarBar( + viewModel: viewModel, + isUploadingAttachment: isUploadingAttachment, + importAttachment: importAttachment, + applyCommand: applyCommand, + isShowingLinkPrompt: $isShowingLinkPrompt, + isShowingSearchReplace: $isShowingSearchReplace, + isShowingStatusPrompt: $isShowingStatusPrompt, + isShowingMathPrompt: $isShowingMathPrompt, + showMentionPicker: showMentionPicker, + showInlineCommentComposer: showInlineCommentComposer, + dismissKeyboard: dismissKeyboard + ) } } + .padding(.horizontal) .padding(.bottom, 6) .alert("Link", isPresented: $isShowingLinkPrompt) { TextField("URL", text: $linkURLString) @@ -107,7 +100,7 @@ struct NativeEditorToolbar: View { } } -private struct NativeEditorToolbarContent: View { +private struct NativeEditorToolbarBar: View { @Bindable var viewModel: NativeRichEditorViewModel let isUploadingAttachment: Bool let importAttachment: (NativeEditorAttachmentImportKind) -> Void @@ -118,21 +111,46 @@ private struct NativeEditorToolbarContent: View { @Binding var isShowingMathPrompt: Bool let showMentionPicker: () -> Void let showInlineCommentComposer: () -> Void + let dismissKeyboard: () -> Void var body: some View { - GlassEffectContainer(spacing: NativeEditorToolbarMetrics.groupSpacing) { - NativeEditorToolbarGroups( - viewModel: viewModel, - isUploadingAttachment: isUploadingAttachment, - importAttachment: importAttachment, - applyCommand: applyCommand, - isShowingLinkPrompt: $isShowingLinkPrompt, - isShowingSearchReplace: $isShowingSearchReplace, - isShowingStatusPrompt: $isShowingStatusPrompt, - isShowingMathPrompt: $isShowingMathPrompt, - showMentionPicker: showMentionPicker, - showInlineCommentComposer: showInlineCommentComposer - ) + DocmostlyGlassPanel(shape: .capsule, isInteractive: true) { + HStack(spacing: 0) { + ScrollView(.horizontal) { + NativeEditorToolbarGroups( + viewModel: viewModel, + isUploadingAttachment: isUploadingAttachment, + importAttachment: importAttachment, + applyCommand: applyCommand, + isShowingLinkPrompt: $isShowingLinkPrompt, + isShowingSearchReplace: $isShowingSearchReplace, + isShowingStatusPrompt: $isShowingStatusPrompt, + isShowingMathPrompt: $isShowingMathPrompt, + showMentionPicker: showMentionPicker, + showInlineCommentComposer: showInlineCommentComposer + ) + .padding(.horizontal, NativeEditorToolbarMetrics.horizontalPadding) + .padding(.vertical, NativeEditorToolbarMetrics.verticalPadding) + } + .scrollIndicators(.hidden) + .scrollClipDisabled(false) + .frame(maxWidth: .infinity, alignment: .leading) + + Divider() + .padding(.vertical, NativeEditorToolbarMetrics.dividerVerticalPadding) + .accessibilityHidden(true) + + NativeEditorKeyboardDismissToolbarButton(dismissKeyboard: dismissKeyboard) + .padding(.horizontal, NativeEditorToolbarMetrics.horizontalPadding) + .padding(.vertical, NativeEditorToolbarMetrics.verticalPadding) + } + // Prevent transient keyboard safe-area proposals from stretching the horizontal scroller. + .frame(height: NativeEditorToolbarMetrics.barHeight) + .buttonStyle(.plain) + .controlSize(.regular) + .labelStyle(.iconOnly) + .accessibilityElement(children: .contain) + .accessibilityLabel("Editor toolbar") } } } @@ -151,22 +169,16 @@ private struct NativeEditorToolbarGroups: View { var body: some View { HStack(spacing: NativeEditorToolbarMetrics.groupSpacing) { - NativeEditorToolbarSurface { - NativeEditorHistoryToolbarGroup(viewModel: viewModel) - } + NativeEditorHistoryToolbarGroup(viewModel: viewModel) - NativeEditorToolbarSurface { - NativeEditorBlockCommandMenu(viewModel: viewModel, applyCommand: applyCommand) - } + NativeEditorBlockCommandMenu(viewModel: viewModel, applyCommand: applyCommand) - NativeEditorToolbarSurface { - NativeEditorQuickFormattingToolbarGroup( - viewModel: viewModel, - isShowingLinkPrompt: $isShowingLinkPrompt - ) - } + NativeEditorQuickFormattingToolbarGroup( + viewModel: viewModel, + isShowingLinkPrompt: $isShowingLinkPrompt + ) - NativeEditorToolbarSurface { + HStack(spacing: NativeEditorToolbarMetrics.controlSpacing) { NativeEditorAttachmentToolbarGroup( isUploading: isUploadingAttachment, importAttachment: importAttachment @@ -182,9 +194,6 @@ private struct NativeEditorToolbarGroups: View { ) } } - .buttonStyle(.plain) - .controlSize(.regular) - .labelStyle(.iconOnly) } } @@ -192,16 +201,9 @@ private struct NativeEditorKeyboardDismissToolbarButton: View { let dismissKeyboard: () -> Void var body: some View { - GlassEffectContainer(spacing: NativeEditorToolbarMetrics.groupSpacing) { - NativeEditorToolbarSurface { - Button(action: dismissKeyboard) { - Label("Dismiss Keyboard", systemImage: "keyboard.chevron.compact.down") - } - .nativeEditorToolbarControlFrame() - } + Button(action: dismissKeyboard) { + Label("Dismiss Keyboard", systemImage: "keyboard.chevron.compact.down") } - .buttonStyle(.plain) - .controlSize(.regular) - .labelStyle(.iconOnly) + .nativeEditorToolbarControlFrame() } } diff --git a/docmostly/Features/Editor/NativeEditorToolbarMetrics.swift b/docmostly/Features/Editor/NativeEditorToolbarMetrics.swift index 2f0aab5..5c333ad 100644 --- a/docmostly/Features/Editor/NativeEditorToolbarMetrics.swift +++ b/docmostly/Features/Editor/NativeEditorToolbarMetrics.swift @@ -7,4 +7,6 @@ enum NativeEditorToolbarMetrics { static let controlSideLength: CGFloat = 38 static let horizontalPadding: CGFloat = 10 static let verticalPadding: CGFloat = 4 + static let dividerVerticalPadding: CGFloat = 8 + static let barHeight = controlSideLength + (verticalPadding * 2) } diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift index 878d7bd..a606bcc 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift @@ -288,10 +288,11 @@ extension NativeRichEditorViewModel { } } - func deleteBlock(_ blockID: UUID) { + @discardableResult + func deleteBlock(_ blockID: UUID) -> UUID? { + var destinationBlockID: UUID? performUndoableEdit { guard let index = document.blocks.firstIndex(where: { $0.id == blockID }) else { return } - let deletedBlockWasActive = activeBlockID == blockID let deletedBlockWasSelected = selectedBlockID == blockID if document.blocks.count == 1 { @@ -305,19 +306,20 @@ extension NativeRichEditorViewModel { if deletedBlockWasSelected { selectedBlockID = nil + } + if visibleBlockControlsID == blockID { visibleBlockControlsID = nil } - if deletedBlockWasActive || deletedBlockWasSelected { - focusEditableBlockAfterDeletion(at: index) - } + destinationBlockID = focusEditableBlockAfterDeletion(at: index) } + return destinationBlockID } - private func focusEditableBlockAfterDeletion(at deletedIndex: Int) { + private func focusEditableBlockAfterDeletion(at deletedIndex: Int) -> UUID? { guard document.blocks.isEmpty == false else { activeBlockID = nil - return + return nil } let precedingIndices = document.blocks.indices.prefix(min(deletedIndex, document.blocks.count)).reversed() @@ -326,7 +328,7 @@ extension NativeRichEditorViewModel { followingIndices.first(where: { document.blocks[$0].isEditable }) guard let focusIndex else { activeBlockID = nil - return + return nil } let insertionOffset = focusIndex < deletedIndex ? document.blocks[focusIndex].text.characters.count : 0 @@ -336,11 +338,13 @@ extension NativeRichEditorViewModel { ) document.blocks[focusIndex].selection = AttributedTextSelection(insertionPoint: insertionIndex) activeBlockID = document.blocks[focusIndex].id + return activeBlockID } - func deleteSelectedBlock() { - guard let selectedBlockID else { return } - deleteBlock(selectedBlockID) + @discardableResult + func deleteSelectedBlock() -> UUID? { + guard let selectedBlockID else { return nil } + return deleteBlock(selectedBlockID) } func moveBlock(_ blockID: UUID, before targetBlockID: UUID) { diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+Collaboration.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+Collaboration.swift index 4121433..7338356 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+Collaboration.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+Collaboration.swift @@ -223,32 +223,21 @@ extension NativeRichEditorViewModel { return } - guard isDirty else { - applyCleanCRDTDocumentSnapshot(snapshot) + if pendingRemoteCRDTSnapshot != nil { + deferCRDTDocumentSnapshot(snapshot) return } - let hasEarlierDeferredConflict = pendingRemotePage != nil || - pendingRemoteUpdate != nil || - pendingRemoteCRDTSnapshot != nil || - realtimeStatus == .conflict let titleMatches = snapshot.title == nil || snapshot.title == title let documentMatches = snapshot.document.proseMirrorDocument.isCollaborationEquivalent( to: document.proseMirrorDocument ) - - if hasEarlierDeferredConflict == false, titleMatches, documentMatches { - pendingRemotePage = nil - pendingRemoteUpdate = nil - pendingRemoteCRDTSnapshot = nil - hasDurablyPersistedLocalCRDTDraft = false - markRemoteBaseline(updatedAt: snapshot.updatedAt ?? lastRemoteUpdatedAt) - lastKnownSnapshot = makeHistorySnapshot() - isDirty = true + if titleMatches, documentMatches { + acknowledgeCRDTDocumentProjection(snapshot) return } - deferCRDTDocumentSnapshot(snapshot) + applyAuthoritativeCRDTDocumentProjection(snapshot) } @discardableResult @@ -323,6 +312,8 @@ extension NativeRichEditorViewModel { _ engine: any NativeEditorCRDTDocumentEngine, restoredLocalState: Bool = false ) { + documentSession?.detachEditor(sourceID: crdtSessionAttachmentID) + documentSession = nil crdtDocumentEngine = engine crdtSyncCoordinator = makeCRDTSyncCoordinator(for: engine) isCRDTEngineReadyForLocalChanges = restoredLocalState || engine.requiresInitialRemoteSnapshot == false @@ -333,7 +324,21 @@ extension NativeRichEditorViewModel { _ session: DocumentSession, restoredLocalState: Bool = false ) { + documentSession?.detachEditor(sourceID: crdtSessionAttachmentID) documentSession = session + session.attachEditor( + sourceID: crdtSessionAttachmentID, + localChangeBarrier: { [weak self] in + guard let self else { return false } + try await self.waitForStableCRDTLocalChangeBarrier() + return true + }, + remoteProjectionHandler: { [weak self] snapshot in + guard let self else { return false } + self.applyCRDTDocumentSnapshot(snapshot) + return true + } + ) crdtDocumentEngine = session.documentEngine crdtSyncCoordinator = session.syncCoordinator isCRDTEngineReadyForLocalChanges = restoredLocalState || @@ -361,7 +366,7 @@ extension NativeRichEditorViewModel { } private func applyOrderedCRDTRemoteUpdate(_ update: Data) async throws { - await waitForStableCRDTLocalChangeBarrier() + try await waitForStableCRDTLocalChangeBarrier() try Task.checkCancellation() guard let crdtDocumentEngine else { return } @@ -623,6 +628,91 @@ extension NativeRichEditorViewModel { isDirty = false } + private func acknowledgeCRDTDocumentProjection(_ snapshot: NativeEditorCRDTDocumentSnapshot) { + hasDurablyPersistedLocalCRDTDraft = false + recordCRDTProjectionTimestamp(snapshot.updatedAt) + lastKnownSnapshot = makeHistorySnapshot() + if pendingRemoteUpdate == nil { + realtimeStatus = .connected + } + } + + private func applyAuthoritativeCRDTDocumentProjection( + _ snapshot: NativeEditorCRDTDocumentSnapshot + ) { + let hadLocalDocumentChanges = document != lastSavedDocument + let hadLocalTitleChanges = title != lastSavedTitle + let previousPendingUpdate = pendingRemoteUpdate + let reconciledDocument = snapshot.document.reconcilingEditorState(from: document) + let documentChanged = reconciledDocument.proseMirrorDocument.isCollaborationEquivalent( + to: document.proseMirrorDocument + ) == false + + isApplyingHistory = true + if documentChanged { + document = reconciledDocument + retainValidAuthoringState() + } + if let projectedTitle = snapshot.title { + if hadLocalTitleChanges, projectedTitle != title { + pendingRemoteUpdate = NativeEditorRemoteUpdate( + updatedAt: snapshot.updatedAt ?? previousPendingUpdate?.updatedAt, + title: projectedTitle, + lastUpdatedBy: previousPendingUpdate?.lastUpdatedBy + ) + } else { + title = projectedTitle + if hadLocalTitleChanges == false { + lastSavedTitle = projectedTitle + rebaseEditingHistoryTitle(to: projectedTitle) + } + } + } + isApplyingHistory = false + + if hadLocalDocumentChanges == false { + lastSavedDocument = document + } + pendingRemotePage = nil + pendingRemoteCRDTSnapshot = nil + hasDurablyPersistedLocalCRDTDraft = false + retainedReadOnlyDraftSnapshot = nil + recordCRDTProjectionTimestamp(snapshot.updatedAt) + + if documentChanged { + resolvedRemoteCursors = [] + resetEditingHistory() + notifyLocalAwarenessChanged() + } else { + lastKnownSnapshot = makeHistorySnapshot() + } + + isDirty = hadLocalDocumentChanges || hadLocalTitleChanges + realtimeStatus = pendingRemoteUpdate == nil ? .connected : .conflict + } + + private func retainValidAuthoringState() { + let blockIDs = Set(document.blocks.map(\.id)) + if let activeBlockID, blockIDs.contains(activeBlockID) == false { + self.activeBlockID = nil + } + if let selectedBlockID, blockIDs.contains(selectedBlockID) == false { + self.selectedBlockID = nil + } + if let visibleBlockControlsID, blockIDs.contains(visibleBlockControlsID) == false { + self.visibleBlockControlsID = nil + } + } + + private func recordCRDTProjectionTimestamp(_ projectedUpdatedAt: Date?) { + guard let projectedUpdatedAt else { return } + let latestUpdatedAt = lastRemoteUpdatedAt.map { + max($0, projectedUpdatedAt) + } ?? projectedUpdatedAt + updatedAt = latestUpdatedAt + lastRemoteUpdatedAt = latestUpdatedAt + } + private func deferCRDTDocumentSnapshot(_ snapshot: NativeEditorCRDTDocumentSnapshot) { let pendingUpdate = pendingRemoteUpdate pendingRemotePage = nil diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+Focus.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+Focus.swift new file mode 100644 index 0000000..bda963a --- /dev/null +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+Focus.swift @@ -0,0 +1,27 @@ +import Foundation + +extension NativeRichEditorViewModel { + func textInputDidBeginEditing(blockID: UUID) { + guard canEdit, document.blocks.contains(where: { $0.id == blockID && $0.isEditable }) else { + clearAuthoringState() + return + } + guard focusedTextInputBlockID != blockID || activeBlockID != blockID || isTitleFocused else { return } + + isTitleFocused = false + activeBlockID = blockID + focusedTextInputBlockID = blockID + selectedBlockID = nil + visibleBlockControlsID = nil + notifyLocalAwarenessChanged() + } + + func textInputDidEndEditing(blockID: UUID) { + guard focusedTextInputBlockID == blockID else { return } + + focusedTextInputBlockID = nil + guard activeBlockID == blockID else { return } + activeBlockID = nil + notifyLocalAwarenessChanged() + } +} diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+History.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+History.swift index 804494d..6302131 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+History.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+History.swift @@ -181,19 +181,16 @@ extension NativeRichEditorViewModel { try await crdtLocalChangeTask?.value } - func waitForStableCRDTLocalChangeBarrier() async { + func waitForStableCRDTLocalChangeBarrier() async throws { var stableGeneration = crdtOperationGeneration while Task.isCancelled == false { - do { - try await crdtLocalChangeTask?.value - } catch { - return - } + try await crdtLocalChangeTask?.value guard stableGeneration != crdtOperationGeneration else { return } stableGeneration = crdtOperationGeneration } + throw CancellationError() } func enqueueCRDTSnapshotFlush( diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel.swift b/docmostly/Features/Editor/NativeRichEditorViewModel.swift index 74d098e..2548225 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel.swift @@ -26,6 +26,7 @@ final class NativeRichEditorViewModel { var errorMessage: String? var saveErrorMessage: String? var activeBlockID: UUID? + var focusedTextInputBlockID: UUID? var selectedBlockID: UUID? var visibleBlockControlsID: UUID? var isTitleFocused = false @@ -88,6 +89,7 @@ final class NativeRichEditorViewModel { @ObservationIgnored var crdtDocumentEngine: (any NativeEditorCRDTDocumentEngine)? @ObservationIgnored var documentSession: DocumentSession? @ObservationIgnored var crdtSyncCoordinator: NativeEditorCRDTSyncCoordinator? + @ObservationIgnored let crdtSessionAttachmentID = UUID() @ObservationIgnored var crdtLocalChangeTask: Task? @ObservationIgnored var activeSaveTask: Task? @ObservationIgnored let autosaveCoordinator = NativeEditorAutosaveCoordinator() @@ -121,7 +123,7 @@ final class NativeRichEditorViewModel { } var isEditing: Bool { - isTitleFocused || activeBlockID != nil + isTitleFocused || focusedTextInputBlockID != nil } var currentPageID: String { @@ -300,6 +302,7 @@ final class NativeRichEditorViewModel { } isTitleFocused = true activeBlockID = nil + focusedTextInputBlockID = nil selectedBlockID = nil visibleBlockControlsID = nil notifyLocalAwarenessChanged() @@ -310,6 +313,12 @@ final class NativeRichEditorViewModel { clearAuthoringState() return } + guard document.blocks.contains(where: { $0.id == blockID && $0.isEditable }) else { return } + guard + isTitleFocused || activeBlockID != blockID || selectedBlockID != nil || visibleBlockControlsID != nil + else { + return + } isTitleFocused = false activeBlockID = blockID selectedBlockID = nil @@ -320,6 +329,7 @@ final class NativeRichEditorViewModel { func clearFocus() { isTitleFocused = false activeBlockID = nil + focusedTextInputBlockID = nil notifyLocalAwarenessChanged() } @@ -328,6 +338,7 @@ final class NativeRichEditorViewModel { guard document.blocks.contains(where: { $0.id == blockID }) else { return } isTitleFocused = false activeBlockID = nil + focusedTextInputBlockID = nil visibleBlockControlsID = blockID selectedBlockID = selectedBlockID == blockID ? nil : blockID notifyLocalAwarenessChanged() @@ -342,6 +353,7 @@ final class NativeRichEditorViewModel { guard document.blocks.contains(where: { $0.id == blockID }) else { return } isTitleFocused = false activeBlockID = nil + focusedTextInputBlockID = nil visibleBlockControlsID = blockID notifyLocalAwarenessChanged() } @@ -404,6 +416,7 @@ final class NativeRichEditorViewModel { func clearAuthoringState() { isTitleFocused = false activeBlockID = nil + focusedTextInputBlockID = nil selectedBlockID = nil visibleBlockControlsID = nil } diff --git a/docmostly/Features/PageReader/PageReaderView+Actions.swift b/docmostly/Features/PageReader/PageReaderView+Actions.swift index 343ccb3..a2b3baa 100644 --- a/docmostly/Features/PageReader/PageReaderView+Actions.swift +++ b/docmostly/Features/PageReader/PageReaderView+Actions.swift @@ -114,6 +114,7 @@ extension PageReaderView { case .block(let blockID): editorViewModel.focus(blockID: blockID) case nil: + guard editorViewModel.isTitleFocused else { return } editorViewModel.clearFocus() autosaveInlineEdits() } diff --git a/docmostly/Features/PageTree/PageTreeNavigationState.swift b/docmostly/Features/PageTree/PageTreeNavigationState.swift new file mode 100644 index 0000000..60f8819 --- /dev/null +++ b/docmostly/Features/PageTree/PageTreeNavigationState.swift @@ -0,0 +1,7 @@ +nonisolated struct PageTreeNavigationState: Equatable, Sendable { + var spaceSettingsSpaceID: String? + + mutating func showSpaceSettings(spaceID: String) { + spaceSettingsSpaceID = spaceID + } +} diff --git a/docmostly/Features/PageTree/PageTreeView.swift b/docmostly/Features/PageTree/PageTreeView.swift index 27a2bb0..8b12070 100644 --- a/docmostly/Features/PageTree/PageTreeView.swift +++ b/docmostly/Features/PageTree/PageTreeView.swift @@ -11,6 +11,7 @@ struct PageTreeView: View { @State private var copyRequest: PageTreeNode? @State private var isShowingTrash = false @State private var initializedBrowserSpaceID: String? + @State private var navigationState = PageTreeNavigationState() let space: DocmostSpace @@ -132,6 +133,9 @@ struct PageTreeView: View { .navigationDestination(for: PageTreeNode.self) { node in PageReaderDestinationView(pageID: node.slugId) } + .navigationDestination(item: $navigationState.spaceSettingsSpaceID) { spaceID in + SpaceSettingsDestinationView(spaceID: spaceID) + } .sheet(item: $creationRequest) { request in PageCreationSheet(request: request) { title in await createPage(title: title, parentPageId: request.parentPageId) @@ -248,7 +252,7 @@ struct PageTreeView: View { } private func showSpaceSettings() { - appState.selectSidebarUtilityDestination(.settings, returningTo: .space(space.id)) + navigationState.showSpaceSettings(spaceID: space.id) } private func refreshPages() async { diff --git a/docmostly/Features/Settings/SettingsManagementViewModel.swift b/docmostly/Features/Settings/SettingsManagementViewModel.swift index a760129..544e706 100644 --- a/docmostly/Features/Settings/SettingsManagementViewModel.swift +++ b/docmostly/Features/Settings/SettingsManagementViewModel.swift @@ -19,13 +19,20 @@ final class SettingsManagementViewModel { private var currentUserRole: String? var canManageWorkspace: Bool { - currentUserRole == "owner" || currentUserRole == "admin" + SpaceManagementAuthorization.canManageWorkspace(role: currentUserRole) } var currentUserIsOwner: Bool { currentUserRole == "owner" } + func canManageSpace(_ space: DocmostSpace) -> Bool { + SpaceManagementAuthorization.canManageSpace( + workspaceRole: currentUserRole, + membershipRole: space.membership?.role + ) + } + var hasWorkspaceChanges: Bool { guard let workspace else { return false } return workspaceDraft.hasChanges( diff --git a/docmostly/Features/Settings/SpaceManagementAuthorization.swift b/docmostly/Features/Settings/SpaceManagementAuthorization.swift new file mode 100644 index 0000000..c6e0868 --- /dev/null +++ b/docmostly/Features/Settings/SpaceManagementAuthorization.swift @@ -0,0 +1,9 @@ +nonisolated enum SpaceManagementAuthorization { + static func canManageWorkspace(role: String?) -> Bool { + role == "owner" || role == "admin" + } + + static func canManageSpace(workspaceRole: String?, membershipRole: String?) -> Bool { + canManageWorkspace(role: workspaceRole) || membershipRole == "admin" + } +} diff --git a/docmostly/Features/Settings/SpaceSettingsDestinationView.swift b/docmostly/Features/Settings/SpaceSettingsDestinationView.swift new file mode 100644 index 0000000..9c87e80 --- /dev/null +++ b/docmostly/Features/Settings/SpaceSettingsDestinationView.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct SpaceSettingsDestinationView: View { + @Environment(AppState.self) private var appState + + let spaceID: String + let showsCloseButton: Bool + + init(spaceID: String, showsCloseButton: Bool = false) { + self.spaceID = spaceID + self.showsCloseButton = showsCloseButton + } + + var body: some View { + Group { + if let space { + SpaceSettingsDetailView( + space: space, + canManage: canManage(space), + showsCloseButton: showsCloseButton + ) + .id(space.id) + } else { + ContentUnavailableView("Space unavailable", systemImage: "square.stack.3d.up") + } + } + } + + private var space: DocmostSpace? { + appState.spaces.first { $0.id == spaceID } + } + + private func canManage(_ space: DocmostSpace) -> Bool { + SpaceManagementAuthorization.canManageSpace( + workspaceRole: appState.currentUser?.user.role, + membershipRole: space.membership?.role + ) + } +} diff --git a/docmostly/Features/Settings/SpaceSettingsDetailFormView.swift b/docmostly/Features/Settings/SpaceSettingsDetailFormView.swift index d81d0f2..d0f5db5 100644 --- a/docmostly/Features/Settings/SpaceSettingsDetailFormView.swift +++ b/docmostly/Features/Settings/SpaceSettingsDetailFormView.swift @@ -20,6 +20,9 @@ struct SpaceSettingsDetailFormView: View { selectedTabContent .frame(maxWidth: .infinity, alignment: .topLeading) } + #if os(iOS) + .frame(maxHeight: .infinity, alignment: .top) + #endif .navigationTitle(viewModel.space.name) .toolbar { if showsCloseButton { @@ -65,7 +68,7 @@ struct SpaceSettingsDetailFormView: View { Text("Details") .font(.headline) - SpaceSettingsLabeledRow(title: "Icon") { + SpaceSettingsLabeledRow(title: "Icon", alignment: .center) { SpaceIconView(space: viewModel.space, size: 44) } @@ -314,22 +317,30 @@ private struct SpaceSettingsPanelScrollView: View { .padding(.top, 18) .padding(.bottom, 24) } + #if os(macOS) .frame(maxHeight: maxHeight) .fixedSize(horizontal: false, vertical: maxHeight == nil) + #endif } } private struct SpaceSettingsLabeledRow: View { let title: String + let alignment: VerticalAlignment @ViewBuilder let content: Content - init(title: String, @ViewBuilder content: () -> Content) { + init( + title: String, + alignment: VerticalAlignment = .firstTextBaseline, + @ViewBuilder content: () -> Content + ) { self.title = title + self.alignment = alignment self.content = content() } var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 12) { + HStack(alignment: alignment, spacing: 12) { Text(title) .frame(width: SpaceSettingsDialogMetrics.labelWidth, alignment: .trailing) diff --git a/docmostly/Features/Settings/SpaceSettingsDialog.swift b/docmostly/Features/Settings/SpaceSettingsDialog.swift index a39ef69..85272e4 100644 --- a/docmostly/Features/Settings/SpaceSettingsDialog.swift +++ b/docmostly/Features/Settings/SpaceSettingsDialog.swift @@ -2,28 +2,17 @@ import SwiftUI #if os(macOS) struct SpaceSettingsDialog: View { - @Environment(AppState.self) private var appState - @State private var viewModel = SettingsManagementViewModel() - let space: DocmostSpace var body: some View { NavigationStack { - SpaceSettingsDetailView( - space: space, - canManage: canManage, + SpaceSettingsDestinationView( + spaceID: space.id, showsCloseButton: true ) } .frame(width: 560) .fixedSize(horizontal: false, vertical: true) - .task { - viewModel.seed(from: appState) - } - } - - private var canManage: Bool { - viewModel.canManageWorkspace || space.membership?.role == "admin" } } #endif diff --git a/docmostly/Features/Settings/SpacesSettingsView.swift b/docmostly/Features/Settings/SpacesSettingsView.swift index 395e6bc..8b0859d 100644 --- a/docmostly/Features/Settings/SpacesSettingsView.swift +++ b/docmostly/Features/Settings/SpacesSettingsView.swift @@ -10,7 +10,7 @@ struct SpacesSettingsView: View { List { ForEach(filteredSpaces) { space in NavigationLink { - SpaceSettingsDetailView(space: space, canManage: canManage(space)) + SpaceSettingsDetailView(space: space, canManage: viewModel.canManageSpace(space)) } label: { SpaceRowView(space: space) } @@ -50,8 +50,4 @@ struct SpacesSettingsView: View { private func showCreateSpace() { isShowingCreateSpace = true } - - private func canManage(_ space: DocmostSpace) -> Bool { - viewModel.canManageWorkspace || space.membership?.role == "admin" - } } diff --git a/docmostly/Features/Spaces/MacWorkspaceSidebarView.swift b/docmostly/Features/Spaces/MacWorkspaceSidebarView.swift index 0dae413..ebf03fe 100644 --- a/docmostly/Features/Spaces/MacWorkspaceSidebarView.swift +++ b/docmostly/Features/Spaces/MacWorkspaceSidebarView.swift @@ -70,6 +70,8 @@ struct MacWorkspaceSidebarView: View { action: beginCreateRoot ) + Divider() + MacSidebarPagesHeaderView( space: selectedSpace, viewModel: viewModel, diff --git a/docmostlyTests/App/AppStateNavigationSelectionTests.swift b/docmostlyTests/App/AppStateNavigationSelectionTests.swift index 6101911..cf2c6b8 100644 --- a/docmostlyTests/App/AppStateNavigationSelectionTests.swift +++ b/docmostlyTests/App/AppStateNavigationSelectionTests.swift @@ -60,18 +60,7 @@ struct AppStateNavigationSelectionTests { #expect(appState.selectedPageID == nil) } - @Test func leavingSpaceSettingsReturnsToTheOriginatingSpace() { - let appState = makeAppState() - appState.selectSpace(id: "space-1") - appState.selectSidebarUtilityDestination(.settings, returningTo: .space("space-1")) - - appState.selectSidebarDestination(nil) - - #expect(appState.selectedSidebarDestination == .space("space-1")) - #expect(appState.selectedSpaceID == "space-1") - } - - @Test func leavingAUtilityDestinationWithoutAReturnDestinationShowsTheSidebar() { + @Test func leavingAUtilityDestinationShowsTheSidebar() { let appState = makeAppState() appState.selectSpace(id: "space-1") appState.selectSidebarUtilityDestination(.settings) diff --git a/docmostlyTests/Editor/CRDTProjectionMergeTests.swift b/docmostlyTests/Editor/CRDTProjectionMergeTests.swift new file mode 100644 index 0000000..b2b44ff --- /dev/null +++ b/docmostlyTests/Editor/CRDTProjectionMergeTests.swift @@ -0,0 +1,94 @@ +import Foundation +import SwiftUI +import Testing +@testable import docmostly + +@MainActor +struct CRDTProjectionMergeTests { + @Test func mergedProjectionKeepsLocalTextAndAddsCollaboratorTextWithoutApproval() { + let engine = SnapshotCRDTDocumentEngine() + let viewModel = NativeRichEditorViewModel( + pageID: "page-1", + initialTitle: "Daily notes", + crdtDocumentEngine: engine + ) + viewModel.document = document(firstText: "First", secondText: "Second") + viewModel.lastSavedDocument = viewModel.document + viewModel.resetEditingHistory() + viewModel.document.blocks[0].text = AttributedString("First by me") + viewModel.handleDocumentChanged() + + viewModel.applyCRDTDocumentSnapshot(NativeEditorCRDTDocumentSnapshot( + document: document(firstText: "First by me", secondText: "Second by Alice"), + updatedAt: Date(timeIntervalSince1970: 20) + )) + + #expect(viewModel.document.blocks.map { String($0.text.characters) } == [ + "First by me", + "Second by Alice" + ]) + #expect(viewModel.pendingRemoteCRDTSnapshot == nil) + #expect(viewModel.realtimeStatus == .connected) + #expect(viewModel.isDirty) + } + + @Test func mergedProjectionPreservesFocusedBlockAndMovesCaretAfterRemoteInsertion() throws { + let engine = SnapshotCRDTDocumentEngine() + let viewModel = NativeRichEditorViewModel( + pageID: "page-1", + initialTitle: "Daily notes", + crdtDocumentEngine: engine + ) + viewModel.document = document(text: "World") + viewModel.lastSavedDocument = viewModel.document + viewModel.resetEditingHistory() + let blockID = viewModel.document.blocks[0].id + let insertionPoint = viewModel.document.blocks[0].text.endIndex + viewModel.document.blocks[0].selection = AttributedTextSelection(insertionPoint: insertionPoint) + viewModel.activeBlockID = blockID + + viewModel.applyCRDTDocumentSnapshot(snapshot(text: "Hello World", updatedAt: 20)) + + #expect(viewModel.document.blocks[0].id == blockID) + #expect(viewModel.activeBlockID == blockID) + #expect(try #require(viewModel.currentLocalTextSelection()).anchor.characterOffset == 11) + #expect(viewModel.pendingRemoteCRDTSnapshot == nil) + #expect(viewModel.realtimeStatus == .connected) + } + + private func snapshot( + text: String, + updatedAt: TimeInterval + ) -> NativeEditorCRDTDocumentSnapshot { + NativeEditorCRDTDocumentSnapshot( + title: "Daily notes", + document: document(text: text), + updatedAt: Date(timeIntervalSince1970: updatedAt) + ) + } + + private func document(text: String) -> NativeEditorDocument { + NativeEditorDocument(proseMirrorDocument: ProseMirrorDocument(content: [ + ProseMirrorNode( + type: "paragraph", + attrs: ["id": .string("stable-test-anchor")], + content: [ProseMirrorNode(type: "text", text: text)] + ) + ])) + } + + private func document(firstText: String, secondText: String) -> NativeEditorDocument { + NativeEditorDocument(proseMirrorDocument: ProseMirrorDocument(content: [ + ProseMirrorNode( + type: "paragraph", + attrs: ["id": .string("first-stable-anchor")], + content: [ProseMirrorNode(type: "text", text: firstText)] + ), + ProseMirrorNode( + type: "paragraph", + attrs: ["id": .string("second-stable-anchor")], + content: [ProseMirrorNode(type: "text", text: secondText)] + ) + ])) + } +} diff --git a/docmostlyTests/Editor/NativeEditorBlockRowPolicyTests.swift b/docmostlyTests/Editor/NativeEditorBlockRowPolicyTests.swift index 35e5a77..a9d44bd 100644 --- a/docmostlyTests/Editor/NativeEditorBlockRowPolicyTests.swift +++ b/docmostlyTests/Editor/NativeEditorBlockRowPolicyTests.swift @@ -27,4 +27,13 @@ struct NativeEditorBlockRowPolicyTests { #expect(NativeEditorBlockRowPolicy.allowsTaskToggle(isReadOnly: false)) #expect(NativeEditorBlockRowPolicy.allowsTaskToggle(isReadOnly: true) == false) } + + @Test func readModePreservesPrefixesForListAndUnsupportedBlocks() { + #expect(NativeEditorBlockRowPolicy.hasVisiblePrefix(kind: .bulletListItem)) + #expect(NativeEditorBlockRowPolicy.hasVisiblePrefix(kind: .orderedListItem(ordinal: 2))) + #expect(NativeEditorBlockRowPolicy.hasVisiblePrefix(kind: .taskListItem(isChecked: true))) + #expect(NativeEditorBlockRowPolicy.hasVisiblePrefix(kind: .unsupported(type: "custom"))) + #expect(NativeEditorBlockRowPolicy.hasVisiblePrefix(kind: .paragraph) == false) + #expect(NativeEditorBlockRowPolicy.hasVisiblePrefix(kind: .blockquote) == false) + } } diff --git a/docmostlyTests/Editor/NativeEditorCRDTDocumentSnapshotTests.swift b/docmostlyTests/Editor/NativeEditorCRDTDocumentSnapshotTests.swift index e4c737d..76c012e 100644 --- a/docmostlyTests/Editor/NativeEditorCRDTDocumentSnapshotTests.swift +++ b/docmostlyTests/Editor/NativeEditorCRDTDocumentSnapshotTests.swift @@ -34,7 +34,7 @@ struct NativeEditorCRDTDocumentSnapshotTests { #expect(viewModel.isDirty == false) } - @Test func defersDivergentCRDTSnapshotWithoutReplacingDirtyLocalDocument() { + @Test func appliesMergedCRDTSnapshotWithoutConflictingWithDirtyLocalDocument() { let engine = SnapshotCRDTDocumentEngine() let viewModel = NativeRichEditorViewModel( pageID: "page-1", @@ -55,17 +55,15 @@ struct NativeEditorCRDTDocumentSnapshotTests { viewModel.applyCRDTDocumentSnapshot(snapshot) - #expect(viewModel.document.blocks.map { String($0.text.characters) } == ["Local draft"]) + #expect(viewModel.document.blocks.map { String($0.text.characters) } == ["Merged draft"]) #expect(viewModel.isDirty == true) - #expect(viewModel.pendingRemoteUpdate?.title == "Local") - #expect( - viewModel.pendingRemoteCRDTSnapshot?.document.proseMirrorDocument == - snapshot.document.proseMirrorDocument - ) - #expect(viewModel.realtimeStatus == .conflict) + #expect(viewModel.pendingRemoteUpdate == nil) + #expect(viewModel.pendingRemoteCRDTSnapshot == nil) + #expect(viewModel.realtimeStatus == .connected) + #expect(viewModel.canUndo == false) } - @Test func laterMatchingOrPartialSnapshotCannotClearEarlierDeferredConflict() { + @Test func laterMergedSnapshotsRemainAutomaticWhileLocalAutosaveIsDirty() { let engine = SnapshotCRDTDocumentEngine() let localText = "Second line survives autosave — merged after Backspace" let viewModel = NativeRichEditorViewModel( @@ -83,11 +81,13 @@ struct NativeEditorCRDTDocumentSnapshotTests { viewModel.applyCRDTDocumentSnapshot(snapshot(text: localText, updatedAt: 21)) viewModel.applyCRDTDocumentSnapshot(snapshot(text: "Second line survives autosave", updatedAt: 22)) - #expect(viewModel.document.blocks.map { String($0.text.characters) } == [localText]) + #expect(viewModel.document.blocks.map { String($0.text.characters) } == [ + "Second line survives autosave" + ]) #expect(viewModel.lastSavedDocument.blocks.map { String($0.text.characters) } == ["Original"]) - #expect(viewModel.pendingRemoteCRDTSnapshot?.updatedAt == Date(timeIntervalSince1970: 22)) - #expect(viewModel.pendingRemoteUpdate != nil) - #expect(viewModel.realtimeStatus == .conflict) + #expect(viewModel.pendingRemoteCRDTSnapshot == nil) + #expect(viewModel.pendingRemoteUpdate == nil) + #expect(viewModel.realtimeStatus == .connected) #expect(viewModel.isDirty) } @@ -150,6 +150,46 @@ struct NativeEditorCRDTDocumentSnapshotTests { #expect(viewModel.isDirty) } + @Test func ownCanonicalProjectionDoesNotShowConflictOrReplaceTheLiveDraft() { + let engine = SnapshotCRDTDocumentEngine() + let viewModel = NativeRichEditorViewModel( + pageID: "page-1", + initialTitle: "Daily notes", + crdtDocumentEngine: engine + ) + viewModel.document = document(text: "Original") + viewModel.lastSavedDocument = viewModel.document + viewModel.resetEditingHistory() + viewModel.document.blocks[0].text = AttributedString("Current draft") + viewModel.handleDocumentChanged() + let liveBlockID = viewModel.document.blocks[0].id + let canonicalDocument = NativeEditorDocument(proseMirrorDocument: ProseMirrorDocument(content: [ + ProseMirrorNode( + type: "paragraph", + attrs: ["id": .string("canonical-anchor")], + content: [ProseMirrorNode(type: "text", text: "Current draft")] + ) + ])) + let ownProjection = NativeEditorCRDTDocumentSnapshot( + title: "Daily notes", + document: canonicalDocument, + updatedAt: Date(timeIntervalSince1970: 20) + ) + #expect(ownProjection.document.proseMirrorDocument.isCollaborationEquivalent( + to: viewModel.document.proseMirrorDocument + ) == false) + + viewModel.applyCRDTDocumentSnapshot(ownProjection) + + #expect(viewModel.document.blocks[0].id == liveBlockID) + #expect(viewModel.document.blocks.map { String($0.text.characters) } == ["Current draft"]) + #expect(viewModel.pendingRemoteCRDTSnapshot == nil) + #expect(viewModel.pendingRemoteUpdate == nil) + #expect(viewModel.realtimeStatus == .connected) + #expect(viewModel.canUndo == false) + #expect(viewModel.isDirty) + } + @Test func paragraphIDChangeIsNotTreatedAsMatchingSelfEcho() { let local = ProseMirrorDocument(content: [ ProseMirrorNode( @@ -198,7 +238,7 @@ struct NativeEditorCRDTDocumentSnapshotTests { } @Test func coordinatorAppliesRemoteUpdateOnlyAfterQueuedLocalIntegration() async throws { - let engine = SnapshotCRDTDocumentEngine() + let engine = SnapshotCRDTDocumentEngine(requiresInitialRemoteSnapshot: true) let viewModel = NativeRichEditorViewModel( pageID: "page-1", initialTitle: "Daily notes", @@ -218,7 +258,7 @@ struct NativeEditorCRDTDocumentSnapshotTests { } @Test func applyInstallsDeferredCRDTSnapshotAndClearsLocalHistory() { - let engine = SnapshotCRDTDocumentEngine() + let engine = SnapshotCRDTDocumentEngine(requiresInitialRemoteSnapshot: true) let viewModel = makeDirtyViewModel(engine: engine) let remoteSnapshot = snapshot(text: "Remote body", title: "Remote title", updatedAt: 20) viewModel.applyCRDTDocumentSnapshot(remoteSnapshot) @@ -236,7 +276,7 @@ struct NativeEditorCRDTDocumentSnapshotTests { } @Test func keepMineRebasesToRemoteAndPublishesRetainedLocalDocument() async throws { - let engine = SnapshotCRDTDocumentEngine() + let engine = SnapshotCRDTDocumentEngine(requiresInitialRemoteSnapshot: true) let viewModel = makeDirtyViewModel(engine: engine) viewModel.applyCRDTDocumentSnapshot(snapshot(text: "Remote body", updatedAt: 20)) @@ -254,7 +294,7 @@ struct NativeEditorCRDTDocumentSnapshotTests { } @Test func capturedConflictCannotAcceptNewerPendingSnapshot() { - let engine = SnapshotCRDTDocumentEngine() + let engine = SnapshotCRDTDocumentEngine(requiresInitialRemoteSnapshot: true) let viewModel = makeDirtyViewModel(engine: engine) let capturedSnapshot = snapshot(text: "First remote body", updatedAt: 20) let newerSnapshot = snapshot(text: "Newer remote body", updatedAt: 21) @@ -271,7 +311,7 @@ struct NativeEditorCRDTDocumentSnapshotTests { } @Test func capturedConflictCannotRejectNewerPendingSnapshot() { - let engine = SnapshotCRDTDocumentEngine() + let engine = SnapshotCRDTDocumentEngine(requiresInitialRemoteSnapshot: true) let viewModel = makeDirtyViewModel(engine: engine) let capturedSnapshot = snapshot(text: "First remote body", updatedAt: 20) let newerSnapshot = snapshot(text: "Newer remote body", updatedAt: 21) @@ -350,10 +390,11 @@ struct NativeEditorCRDTDocumentSnapshotTests { ) ])) } + } @MainActor -private final class SnapshotCRDTDocumentEngine: NativeEditorCRDTDocumentEngine { +final class SnapshotCRDTDocumentEngine: NativeEditorCRDTDocumentEngine { nonisolated let requiresInitialRemoteSnapshot: Bool var snapshotStream: AsyncStream? private(set) var integratedDocuments: [NativeEditorDocument] = [] diff --git a/docmostlyTests/Editor/NativeEditorFocusLifecycleTests.swift b/docmostlyTests/Editor/NativeEditorFocusLifecycleTests.swift new file mode 100644 index 0000000..2b849bf --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorFocusLifecycleTests.swift @@ -0,0 +1,51 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorFocusLifecycleTests { + @Test func nativeTextInputFocusDrivesToolbarVisibilityAndIgnoresStaleBlur() { + let firstBlock = NativeEditorBlock(kind: .paragraph, text: AttributedString("First"), alignment: .left) + let secondBlock = NativeEditorBlock(kind: .paragraph, text: AttributedString("Second"), alignment: .left) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [firstBlock, secondBlock]) + + viewModel.focus(blockID: firstBlock.id) + #expect(viewModel.isEditing == false) + + viewModel.textInputDidBeginEditing(blockID: firstBlock.id) + #expect(viewModel.isEditing == true) + #expect(viewModel.focusedTextInputBlockID == firstBlock.id) + + viewModel.focus(blockID: secondBlock.id) + viewModel.textInputDidBeginEditing(blockID: secondBlock.id) + viewModel.textInputDidEndEditing(blockID: firstBlock.id) + + #expect(viewModel.isEditing == true) + #expect(viewModel.activeBlockID == secondBlock.id) + #expect(viewModel.focusedTextInputBlockID == secondBlock.id) + + viewModel.textInputDidEndEditing(blockID: secondBlock.id) + #expect(viewModel.isEditing == false) + #expect(viewModel.activeBlockID == nil) + } + + @Test func deletingFocusedBlockPreservesDestinationAcrossOldInputBlur() { + let firstBlock = NativeEditorBlock(kind: .paragraph, text: AttributedString("First"), alignment: .left) + let deletedBlock = NativeEditorBlock(kind: .paragraph, text: AttributedString("Delete"), alignment: .left) + let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") + viewModel.document = NativeEditorDocument(blocks: [firstBlock, deletedBlock]) + viewModel.textInputDidBeginEditing(blockID: deletedBlock.id) + + let destinationBlockID = viewModel.deleteBlock(deletedBlock.id) + viewModel.textInputDidEndEditing(blockID: deletedBlock.id) + + #expect(destinationBlockID == firstBlock.id) + #expect(viewModel.activeBlockID == firstBlock.id) + #expect(viewModel.focusedTextInputBlockID == nil) + + viewModel.textInputDidBeginEditing(blockID: firstBlock.id) + #expect(viewModel.isEditing == true) + #expect(viewModel.focusedTextInputBlockID == firstBlock.id) + } +} diff --git a/docmostlyTests/Editor/NativeEditorTextAlignmentTests.swift b/docmostlyTests/Editor/NativeEditorTextAlignmentTests.swift new file mode 100644 index 0000000..e262ddd --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorTextAlignmentTests.swift @@ -0,0 +1,19 @@ +import SwiftUI +import Testing +@testable import docmostly + +struct NativeEditorTextAlignmentTests { + @Test func mapsDocumentAlignmentToReadModeTextAlignment() { + #expect(NativeEditorTextAlignment.left.swiftUITextAlignment == .leading) + #expect(NativeEditorTextAlignment.center.swiftUITextAlignment == .center) + #expect(NativeEditorTextAlignment.right.swiftUITextAlignment == .trailing) + #expect(NativeEditorTextAlignment.justify.swiftUITextAlignment == .leading) + } + + @Test func mapsDocumentAlignmentToReadModeFrameAlignment() { + #expect(NativeEditorTextAlignment.left.swiftUIFrameAlignment == .leading) + #expect(NativeEditorTextAlignment.center.swiftUIFrameAlignment == .center) + #expect(NativeEditorTextAlignment.right.swiftUIFrameAlignment == .trailing) + #expect(NativeEditorTextAlignment.justify.swiftUIFrameAlignment == .leading) + } +} diff --git a/docmostlyTests/Editor/NativeEditorTextMutationTests.swift b/docmostlyTests/Editor/NativeEditorTextMutationTests.swift index 4577715..acb6443 100644 --- a/docmostlyTests/Editor/NativeEditorTextMutationTests.swift +++ b/docmostlyTests/Editor/NativeEditorTextMutationTests.swift @@ -225,6 +225,24 @@ struct NativeEditorTextMutationTests { ) } + @Test func activeBlockHandoffDeactivatesPreviousInputAndActivatesContinuation() { + var previousBlockReconciler = NativeEditorFocusBindingEchoReconciler() + var continuationBlockReconciler = NativeEditorFocusBindingEchoReconciler() + + #expect( + previousBlockReconciler.disposition( + for: false, + platformIsFocused: true + ) == .deactivate + ) + #expect( + continuationBlockReconciler.disposition( + for: true, + platformIsFocused: false + ) == .activate + ) + } + @Test func partialAtomicInlineEditsRemoveWholeAtomWithoutTouchingNeighbors() throws { let atomicText = try textWithAtomicInlineRuns() diff --git a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift index ae8c4ac..93154f1 100644 --- a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift @@ -331,10 +331,11 @@ struct NativeRichEditorViewModelTests { viewModel.document = NativeEditorDocument(blocks: [firstBlock, selectedBlock, lastBlock]) viewModel.selectBlock(selectedBlock.id) - viewModel.deleteSelectedBlock() + let destinationBlockID = viewModel.deleteSelectedBlock() #expect(viewModel.document.blocks.map { String($0.text.characters) } == ["First", "Last"]) #expect(viewModel.selectedBlockID == nil) + #expect(destinationBlockID == firstBlock.id) #expect(viewModel.activeBlockID == firstBlock.id) let selection = try #require(NativeEditorCharacterRange.characterRange( for: viewModel.document.blocks[0].selection, diff --git a/docmostlyTests/PageTree/PageTreeNavigationStateTests.swift b/docmostlyTests/PageTree/PageTreeNavigationStateTests.swift new file mode 100644 index 0000000..896168a --- /dev/null +++ b/docmostlyTests/PageTree/PageTreeNavigationStateTests.swift @@ -0,0 +1,20 @@ +import Testing +@testable import docmostly + +struct PageTreeNavigationStateTests { + @Test func openingSpaceSettingsStoresTheSelectedSpaceID() { + var state = PageTreeNavigationState() + + state.showSpaceSettings(spaceID: "space-1") + + #expect(state.spaceSettingsSpaceID == "space-1") + } + + @Test func openingDifferentSpaceSettingsReplacesThePreviousDestination() { + var state = PageTreeNavigationState(spaceSettingsSpaceID: "space-1") + + state.showSpaceSettings(spaceID: "space-2") + + #expect(state.spaceSettingsSpaceID == "space-2") + } +} diff --git a/docmostlyTests/Persistence/DocumentSessionArchitectureTestSupport.swift b/docmostlyTests/Persistence/DocumentSessionArchitectureTestSupport.swift index 8adb155..f43393f 100644 --- a/docmostlyTests/Persistence/DocumentSessionArchitectureTestSupport.swift +++ b/docmostlyTests/Persistence/DocumentSessionArchitectureTestSupport.swift @@ -5,6 +5,7 @@ import Foundation final class SessionTestDocumentEngine: NativeEditorCRDTDocumentEngine { let requiresInitialRemoteSnapshot = true private(set) var appliedUpdates: [Data] = [] + private(set) var events: [String] = [] private var localSequence = 0 func encodeStateVector() async throws -> Data { @@ -37,6 +38,7 @@ final class SessionTestDocumentEngine: NativeEditorCRDTDocumentEngine { try await validateUpdate(update) if appliedUpdates.contains(update) == false { appliedUpdates.append(update) + events.append("remote") } return NativeEditorCRDTDocumentSnapshot(title: "Page", document: NativeEditorDocument()) } @@ -48,6 +50,7 @@ final class SessionTestDocumentEngine: NativeEditorCRDTDocumentEngine { func integrateLocalChangeForCommit(_ change: NativeEditorCRDTLocalChange) async throws -> [Data] { _ = change localSequence += 1 + events.append("local") return [Data("local-\(localSequence)".utf8)] } diff --git a/docmostlyTests/Persistence/DocumentSessionRemoteOrderingTests.swift b/docmostlyTests/Persistence/DocumentSessionRemoteOrderingTests.swift new file mode 100644 index 0000000..040a5c6 --- /dev/null +++ b/docmostlyTests/Persistence/DocumentSessionRemoteOrderingTests.swift @@ -0,0 +1,46 @@ +import Foundation +import SwiftData +import Testing +@testable import docmostly + +@MainActor +struct DocumentSessionRemoteOrderingTests { + @Test func remoteUpdateWaitsForAttachedEditorLocalIntegration() async throws { + let container = DocmostlyModelContainer.make(isStoredInMemoryOnly: true) + let peer = DocumentLocalPersistencePeer(modelContainer: container) + let factory = SessionTestDocumentEngineFactory() + let key = DocumentStoreKey( + serverBaseURL: "https://docs.example.com", + userID: "user-1", + workspaceID: "workspace-1", + pageID: "page-1" + ) + let registry = DocumentSessionRegistry(localPeer: peer, engineFactory: factory) + let session = try await registry.session( + for: key, + title: "Page", + document: NativeEditorDocument() + ) + let viewModel = NativeRichEditorViewModel(pageID: key.pageID, initialTitle: "Page") + viewModel.configureDocumentSession(session, restoredLocalState: true) + viewModel.document = NativeEditorDocument(blocks: [ + NativeEditorBlock( + kind: .paragraph, + text: AttributedString("Baseline"), + alignment: .left + ) + ]) + viewModel.lastSavedDocument = viewModel.document + viewModel.resetEditingHistory() + viewModel.document.blocks[0].text = AttributedString("Typed before remote") + viewModel.handleDocumentChanged() + + _ = try await #require(session.syncCoordinator).receive( + .update(Data("remote-after-local".utf8)) + ) + + #expect(factory.engines.last?.events == ["local", "remote"]) + #expect(viewModel.pendingRemoteCRDTSnapshot == nil) + #expect(viewModel.realtimeStatus == .connected) + } +} diff --git a/docmostlyTests/Settings/SpaceManagementAuthorizationTests.swift b/docmostlyTests/Settings/SpaceManagementAuthorizationTests.swift new file mode 100644 index 0000000..28640a6 --- /dev/null +++ b/docmostlyTests/Settings/SpaceManagementAuthorizationTests.swift @@ -0,0 +1,42 @@ +import Testing +@testable import docmostly + +struct SpaceManagementAuthorizationTests { + @Test(arguments: ["owner", "admin"]) + func workspaceAdministratorsCanManageSpaces(_ workspaceRole: String) { + #expect( + SpaceManagementAuthorization.canManageSpace( + workspaceRole: workspaceRole, + membershipRole: nil + ) + ) + } + + @Test func spaceAdministratorsCanManageTheirSpace() { + #expect( + SpaceManagementAuthorization.canManageSpace( + workspaceRole: "member", + membershipRole: "admin" + ) + ) + } + + @Test(arguments: ["reader", "writer"]) + func nonAdministrativeSpaceMembersCannotManageSpaces(_ membershipRole: String) { + #expect( + SpaceManagementAuthorization.canManageSpace( + workspaceRole: "member", + membershipRole: membershipRole + ) == false + ) + } + + @Test func membersWithoutSpaceMembershipCannotManageSpaces() { + #expect( + SpaceManagementAuthorization.canManageSpace( + workspaceRole: "member", + membershipRole: nil + ) == false + ) + } +}