Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions Tools/CRDTRuntime/src/docmostly-crdt-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 };
}
Expand Down
80 changes: 78 additions & 2 deletions Tools/CRDTRuntime/test/docmostly-crdt-runtime.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 }]
}))
};
Expand Down
14 changes: 3 additions & 11 deletions docmostly/App/AppState+Navigation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -77,7 +70,6 @@ extension AppState {
}

func resetNavigationSelection() {
sidebarReturnDestination = nil
selectedSidebarDestination = nil
selectedSpaceID = nil
selectedPageID = nil
Expand Down
1 change: 0 additions & 1 deletion docmostly/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ final class AppState {
@ObservationIgnored private(set) var apiClient: DocmostAPIClient?
@ObservationIgnored private var restoreTask: Task<Void, Never>?
@ObservationIgnored private var spacesLoadTask: Task<Bool, Never>?
@ObservationIgnored var sidebarReturnDestination: SidebarDestination?
@ObservationIgnored private var pendingCacheWrites: [CacheWriteOperation] = []
@ObservationIgnored private var cacheWriteTask: Task<Void, Never>?
@ObservationIgnored var offlineReplayTask: Task<Void, Never>?
Expand Down
24 changes: 14 additions & 10 deletions docmostly/DocmostlyCRDTRuntime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 };
}
Expand Down
56 changes: 51 additions & 5 deletions docmostly/Features/Editor/DocumentSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ final class DocumentSession {
@ObservationIgnored private var snapshotContinuations: [
UUID: AsyncStream<NativeEditorCRDTDocumentSnapshot>.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?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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] {
Expand Down Expand Up @@ -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)
}
Expand Down
27 changes: 7 additions & 20 deletions docmostly/Features/Editor/NativeEditorBlockRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import SwiftUI

struct NativeEditorBlockRow: View {
@Binding var block: NativeEditorBlock
let focusedField: FocusState<NativeEditorFocus?>.Binding
let isActive: Bool
let focusRequestID: UUID?
let isSelected: Bool
let isShowingControls: Bool
let isReadOnly: Bool
Expand All @@ -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<Int>) -> Bool
let insertHardBreak: (Range<Int>) -> Bool
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -228,18 +227,6 @@ struct NativeEditorBlockRow: View {
showsControls ? richBlockActions : nil
}

private var blockFocusBinding: Binding<Bool> {
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<Int>) -> Bool {
switch NativeEditorReturnKeyBehavior.resolve(
kind: block.kind,
Expand Down
Loading
Loading