From 597eb28320aeab19cad849e418880d62ff99d99d Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Tue, 7 Jul 2026 21:36:10 +0100 Subject: [PATCH 1/6] feat: add native editor embed blocks --- .../CRDTRuntime/src/docmostly-crdt-runtime.js | 15 - .../test/docmostly-crdt-runtime.test.js | 71 ++- docmostly.xcodeproj/project.pbxproj | 46 +- .../xcshareddata/swiftpm/Package.resolved | 15 + docmostly/DocmostlyCRDTRuntime.js | 14 - .../Editor/NativeEditorBlockKind.swift | 2 +- .../Editor/NativeEditorBlockPrefix.swift | 12 +- .../Editor/NativeEditorBlockRow.swift | 72 ++- .../Editor/NativeEditorBlockSurfaces.swift | 505 ++++++++++++++++++ .../Editor/NativeEditorBodyView.swift | 4 +- .../Editor/NativeEditorDebugPreviewView.swift | 121 +++-- .../NativeEditorDocument+Payloads.swift | 10 +- .../Editor/NativeEditorEmbedBlockView.swift | 254 +++++++++ .../NativeEditorEmbedPresentation.swift | 100 ++++ .../NativeEditorMarkdownParser+Embeds.swift | 10 +- .../NativeEditorRichBlockPreviewView.swift | 119 +---- ...NativeEditorRichBlockPropertyEditors.swift | 11 - .../Editor/NativeEditorSlashCommandMenu.swift | 78 +-- .../Editor/NativeRichEditorViewModel.swift | 21 +- .../PageReader/PageHistoryDetailView.swift | 7 +- .../PageReader/PageReaderView+Actions.swift | 68 ++- .../Features/PageReader/PageReaderView.swift | 5 + docmostly/Features/Spaces/MainShellView.swift | 9 +- .../NativeEditorEmbedPresentationTests.swift | 99 ++++ .../NativeRichEditorViewModelTests.swift | 8 + 25 files changed, 1383 insertions(+), 293 deletions(-) create mode 100644 docmostly.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 docmostly/Features/Editor/NativeEditorBlockSurfaces.swift create mode 100644 docmostly/Features/Editor/NativeEditorEmbedBlockView.swift create mode 100644 docmostly/Features/Editor/NativeEditorEmbedPresentation.swift create mode 100644 docmostlyTests/Editor/NativeEditorEmbedPresentationTests.swift diff --git a/Tools/CRDTRuntime/src/docmostly-crdt-runtime.js b/Tools/CRDTRuntime/src/docmostly-crdt-runtime.js index 0957fb6..8fcbef3 100644 --- a/Tools/CRDTRuntime/src/docmostly-crdt-runtime.js +++ b/Tools/CRDTRuntime/src/docmostly-crdt-runtime.js @@ -9,7 +9,6 @@ import { import { Schema } from "@tiptap/pm/model"; const fragmentName = "default"; -const seedClientID = 1; const schema = new Schema({ nodes: { @@ -223,7 +222,6 @@ class DocmostlyCRDTDocument { this.snapshots = []; this.ydoc = new Y.Doc(); this.fragment = this.ydoc.getXmlFragment(fragmentName); - this.applySeedDocument(seed.title, seed.document); this.ydoc.on("update", (update, origin) => { if (origin === this.localOrigin) { this.localUpdates.push(base64FromBytes(update)); @@ -315,19 +313,6 @@ class DocmostlyCRDTDocument { return snapshots; } - applySeedDocument(title, document) { - this.title = title; - const seedDoc = new Y.Doc(); - seedDoc.clientID = seedClientID; - const seedFragment = seedDoc.getXmlFragment(fragmentName); - const nextDoc = schema.nodeFromJSON(normalizedDocument(document)); - const transactionTarget = { - transact: (operation) => seedDoc.transact(operation, this.remoteOrigin) - }; - updateYFragment(transactionTarget, seedFragment, nextDoc, mappingStateFor(seedFragment)); - Y.applyUpdate(this.ydoc, Y.encodeStateAsUpdate(seedDoc), this.remoteOrigin); - } - applyDocument(title, document, origin) { this.title = title; const nextDoc = schema.nodeFromJSON(normalizedDocument(document)); diff --git a/Tools/CRDTRuntime/test/docmostly-crdt-runtime.test.js b/Tools/CRDTRuntime/test/docmostly-crdt-runtime.test.js index 87599b3..514c572 100644 --- a/Tools/CRDTRuntime/test/docmostly-crdt-runtime.test.js +++ b/Tools/CRDTRuntime/test/docmostly-crdt-runtime.test.js @@ -1,12 +1,29 @@ import assert from "node:assert/strict"; import test from "node:test"; import * as Y from "yjs"; -import { yDocToProsemirrorJSON } from "y-prosemirror"; +import { + initProseMirrorDoc, + updateYFragment, + yDocToProsemirrorJSON +} from "y-prosemirror"; +import { Schema } from "@tiptap/pm/model"; import "../src/docmostly-crdt-runtime.js"; const fragmentName = "default"; +const schema = new Schema({ + nodes: { + doc: { content: "block+" }, + text: { group: "inline" }, + paragraph: { + group: "block", + content: "inline*", + attrs: { textAlign: { default: null } } + } + }, + marks: {} +}); -test("seeds the initial document into Yjs state without broadcasting it as a local update", () => { +test("starts with empty Yjs state without broadcasting fetched page content", () => { const document = globalThis.docmostlyCRDT.createDocument({ pageID: "page-1", title: "Page", @@ -15,15 +32,13 @@ test("seeds the initial document into Yjs state without broadcasting it as a loc assert.deepEqual(document.drainLocalUpdates(), []); - const emptyStateVector = base64FromBytes(Y.encodeStateVector(new Y.Doc())); - const update = document.encodeStateAsUpdate(emptyStateVector); - const ydoc = new Y.Doc(); - Y.applyUpdate(ydoc, bytesFromBase64(update)); - - assert.deepEqual(yDocToProsemirrorJSON(ydoc, fragmentName), paragraphDocument("Seed")); + assert.equal( + document.encodeStateVector(), + base64FromBytes(Y.encodeStateVector(new Y.Doc())) + ); }); -test("seeds independent documents with shared Yjs state so later updates replace the seed", () => { +test("applies remote document update to empty native state", () => { const firstDocument = globalThis.docmostlyCRDT.createDocument({ pageID: "page-1", title: "Page", @@ -52,22 +67,21 @@ test("seeds independent documents with shared Yjs state so later updates replace }]); }); -test("does not duplicate seed content when another seeded document syncs initial state", () => { - const firstDocument = globalThis.docmostlyCRDT.createDocument({ - pageID: "page-1", - title: "Page", - document: paragraphDocument("Seed") - }); - const secondDocument = globalThis.docmostlyCRDT.createDocument({ +test("does not duplicate content when syncing with server-converted ydoc", () => { + const nativeDocument = globalThis.docmostlyCRDT.createDocument({ pageID: "page-1", title: "Page", document: paragraphDocument("Seed") }); + const serverDocument = serverYDocFromJSON(paragraphDocument("Seed")); - const emptyStateVector = base64FromBytes(Y.encodeStateVector(new Y.Doc())); - secondDocument.applyRemoteUpdate(firstDocument.encodeStateAsUpdate(emptyStateVector)); + const serverUpdate = base64FromBytes(Y.encodeStateAsUpdate( + serverDocument, + bytesFromBase64(nativeDocument.encodeStateVector()) + )); + nativeDocument.applyRemoteUpdate(serverUpdate); - assert.deepEqual(secondDocument.drainDocumentSnapshots(), [{ + assert.deepEqual(nativeDocument.drainDocumentSnapshots(), [{ title: "Page", document: paragraphDocument("Seed"), updatedAt: null @@ -84,6 +98,25 @@ function paragraphDocument(text) { }; } +function serverYDocFromJSON(document) { + const ydoc = new Y.Doc(); + const fragment = ydoc.getXmlFragment(fragmentName); + const nextDoc = schema.nodeFromJSON(document); + const transactionTarget = { + transact: (operation) => ydoc.transact(operation) + }; + updateYFragment(transactionTarget, fragment, nextDoc, mappingStateFor(fragment)); + return ydoc; +} + +function mappingStateFor(fragment) { + const state = initProseMirrorDoc(fragment, schema); + return { + mapping: state.mapping, + isOMark: state.meta.isOMark + }; +} + function bytesFromBase64(base64) { if (!base64) return new Uint8Array(); return Uint8Array.from(Buffer.from(base64, "base64")); diff --git a/docmostly.xcodeproj/project.pbxproj b/docmostly.xcodeproj/project.pbxproj index e64f3b1..b717a0b 100644 --- a/docmostly.xcodeproj/project.pbxproj +++ b/docmostly.xcodeproj/project.pbxproj @@ -6,6 +6,12 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 3FEC0B2EC8EF1D5311589C30 /* YouTubePlayerKit in Frameworks */ = {isa = PBXBuildFile; productRef = E03B35CC5DAE0817D7846F6B /* YouTubePlayerKit */; }; + 470013002FFD8842006F135C /* YouTubePlayerKit in Frameworks */ = {isa = PBXBuildFile; productRef = E03B35CC5DAE0817D7846F6B /* YouTubePlayerKit */; }; + BA1551ED7F4ACCE1C5452A4E /* YouTubePlayerKit in Frameworks */ = {isa = PBXBuildFile; productRef = 9ED94873D837F0482A6F599C /* YouTubePlayerKit */; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ 4769951A2FE721BE00A9454F /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -98,6 +104,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + BA1551ED7F4ACCE1C5452A4E /* YouTubePlayerKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -105,6 +112,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 470013002FFD8842006F135C /* YouTubePlayerKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -119,6 +127,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 3FEC0B2EC8EF1D5311589C30 /* YouTubePlayerKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -186,6 +195,7 @@ ); name = DocmostlyMac; packageProductDependencies = ( + 9ED94873D837F0482A6F599C /* YouTubePlayerKit */, ); productName = DocmostlyMac; productReference = 4769950B2FE721BC00A9454F /* DocmostlyMac.app */; @@ -209,6 +219,7 @@ ); name = DocmostlyMacTests; packageProductDependencies = ( + E03B35CC5DAE0817D7846F6B /* YouTubePlayerKit */, ); productName = DocmostlyMacTests; productReference = 476995192FE721BE00A9454F /* DocmostlyMacTests.xctest */; @@ -348,6 +359,9 @@ ); mainGroup = 47D95DC92FE294E500ECEA87; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 29CBB455D32F07D0CAD26246 /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 47D95DD32FE294E500ECEA87 /* Products */; projectDirPath = ""; @@ -492,13 +506,15 @@ ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.1.0; PRODUCT_BUNDLE_IDENTIFIER = ski.chef.docmostly; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -527,13 +543,15 @@ ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.1.0; PRODUCT_BUNDLE_IDENTIFIER = ski.chef.docmostly; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -973,6 +991,30 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 29CBB455D32F07D0CAD26246 /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SvenTiigi/YouTubePlayerKit.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.5; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 9ED94873D837F0482A6F599C /* YouTubePlayerKit */ = { + isa = XCSwiftPackageProductDependency; + package = 29CBB455D32F07D0CAD26246 /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */; + productName = YouTubePlayerKit; + }; + E03B35CC5DAE0817D7846F6B /* YouTubePlayerKit */ = { + isa = XCSwiftPackageProductDependency; + package = 29CBB455D32F07D0CAD26246 /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */; + productName = YouTubePlayerKit; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 47D95DCA2FE294E500ECEA87 /* Project object */; } diff --git a/docmostly.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/docmostly.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..1223ada --- /dev/null +++ b/docmostly.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "838cedc1ff704f14426befc05257203bf3aaf397162004377a05b8e903ad2a57", + "pins" : [ + { + "identity" : "youtubeplayerkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SvenTiigi/YouTubePlayerKit.git", + "state" : { + "revision" : "1ae827573024ab13dd6adea33b25d3eef6ff667c", + "version" : "2.0.5" + } + } + ], + "version" : 3 +} diff --git a/docmostly/DocmostlyCRDTRuntime.js b/docmostly/DocmostlyCRDTRuntime.js index c2a428c..e4e4b86 100644 --- a/docmostly/DocmostlyCRDTRuntime.js +++ b/docmostly/DocmostlyCRDTRuntime.js @@ -13098,7 +13098,6 @@ ${err.toString()}`); // src/docmostly-crdt-runtime.js var fragmentName = "default"; - var seedClientID = 1; var schema = new Schema2({ nodes: { doc: { content: "block+" }, @@ -13303,7 +13302,6 @@ ${err.toString()}`); this.snapshots = []; this.ydoc = new Doc(); this.fragment = this.ydoc.getXmlFragment(fragmentName); - this.applySeedDocument(seed.title, seed.document); this.ydoc.on("update", (update, origin) => { if (origin === this.localOrigin) { this.localUpdates.push(base64FromBytes(update)); @@ -13381,18 +13379,6 @@ ${err.toString()}`); this.snapshots = []; return snapshots; } - applySeedDocument(title, document2) { - this.title = title; - const seedDoc = new Doc(); - seedDoc.clientID = seedClientID; - const seedFragment = seedDoc.getXmlFragment(fragmentName); - const nextDoc = schema.nodeFromJSON(normalizedDocument(document2)); - const transactionTarget = { - transact: (operation) => seedDoc.transact(operation, this.remoteOrigin) - }; - updateYFragment(transactionTarget, seedFragment, nextDoc, mappingStateFor(seedFragment)); - applyUpdate(this.ydoc, encodeStateAsUpdate(seedDoc), this.remoteOrigin); - } applyDocument(title, document2, origin) { this.title = title; const nextDoc = schema.nodeFromJSON(normalizedDocument(document2)); diff --git a/docmostly/Features/Editor/NativeEditorBlockKind.swift b/docmostly/Features/Editor/NativeEditorBlockKind.swift index 74a2822..9cb2f2a 100644 --- a/docmostly/Features/Editor/NativeEditorBlockKind.swift +++ b/docmostly/Features/Editor/NativeEditorBlockKind.swift @@ -102,7 +102,7 @@ nonisolated enum NativeEditorBlockKind: Equatable, Sendable { case .base(let base): base.pageID == nil ? base.previewText : "Base" case .embed(let embed): - embed.provider.map { "\($0) embed" } ?? "Embed" + "\(embed.displayProvider) embed" case .drawio: "Draw.io diagram" case .excalidraw: diff --git a/docmostly/Features/Editor/NativeEditorBlockPrefix.swift b/docmostly/Features/Editor/NativeEditorBlockPrefix.swift index 265db05..1561097 100644 --- a/docmostly/Features/Editor/NativeEditorBlockPrefix.swift +++ b/docmostly/Features/Editor/NativeEditorBlockPrefix.swift @@ -9,7 +9,8 @@ struct NativeEditorBlockPrefix: View { case .bulletListItem: Text("•") case .orderedListItem(let ordinal): - Text(ordinal.formatted()) + Text("\(ordinal.formatted()).") + .monospacedDigit() case .taskListItem(let isChecked): Button( isChecked ? "Mark Incomplete" : "Mark Complete", @@ -19,12 +20,7 @@ struct NativeEditorBlockPrefix: View { } .labelStyle(.iconOnly) .foregroundStyle(isChecked ? DocmostlyTheme.primary : .secondary) - case .blockquote: - Image(systemName: "quote.opening") - .accessibilityHidden(true) - case .codeBlock: - Image(systemName: "curlybraces") - .accessibilityHidden(true) + .buttonStyle(.plain) case .unsupported: Image(systemName: "lock") .accessibilityHidden(true) @@ -34,6 +30,6 @@ struct NativeEditorBlockPrefix: View { } .font(.body) .foregroundStyle(.secondary) - .padding(.top, 10) + .frame(width: 28, alignment: .center) } } diff --git a/docmostly/Features/Editor/NativeEditorBlockRow.swift b/docmostly/Features/Editor/NativeEditorBlockRow.swift index 1e4d395..dcefed1 100644 --- a/docmostly/Features/Editor/NativeEditorBlockRow.swift +++ b/docmostly/Features/Editor/NativeEditorBlockRow.swift @@ -14,6 +14,7 @@ struct NativeEditorBlockRow: View { let richBlockActions: NativeEditorRichBlockEditingActions? let pageID: String let spaceID: String? + let serverURLString: String? let focusBlock: () -> Void let moveBefore: (UUID) -> Void let blockChanged: () -> Void @@ -21,7 +22,7 @@ struct NativeEditorBlockRow: View { let dropText: (String) -> Bool var body: some View { - HStack(alignment: .top, spacing: 10) { + HStack(alignment: .top, spacing: 8) { if showsControls { VStack(spacing: 4) { Button( @@ -46,28 +47,33 @@ struct NativeEditorBlockRow: View { } if showsEditableTextEditor { - TextEditor(text: $block.text, selection: $block.selection) - .font(block.kind.editorFont) - .scrollContentBackground(.hidden) - .scrollDisabled(true) - .contentMargins(.horizontal, 0, for: .scrollContent) - .contentMargins(.vertical, 0, for: .scrollContent) - .frame(maxWidth: .infinity, minHeight: minimumEditorHeight, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) - .focused(focusedField, equals: .block(block.id)) - .accessibilityLabel(block.kind.accessibilityLabel) - .onChange(of: block.selection) { _, _ in - selectionChanged() - } + NativeEditorBlockTextSurface(kind: block.kind) { + TextEditor(text: $block.text, selection: $block.selection) + .font(block.kind.editorFont) + .scrollContentBackground(.hidden) + .scrollDisabled(true) + .contentMargins(.horizontal, 0, for: .scrollContent) + .contentMargins(.vertical, 0, for: .scrollContent) + .frame(maxWidth: .infinity, minHeight: minimumEditorHeight, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .focused(focusedField, equals: .block(block.id)) + .accessibilityLabel(block.kind.accessibilityLabel) + .onChange(of: block.selection) { _, _ in + selectionChanged() + } + } } else if block.isEditable && isReadOnly == false { Button(action: focusBlock) { - NativeEditorRichBlockPreviewView( - block: block, - pageID: pageID, - spaceID: spaceID - ) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(.rect) + NativeEditorBlockTextSurface(kind: block.kind) { + NativeEditorRichBlockPreviewView( + block: block, + pageID: pageID, + spaceID: spaceID, + serverURLString: serverURLString + ) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + } } .buttonStyle(.plain) .accessibilityLabel(block.kind.accessibilityLabel) @@ -75,9 +81,10 @@ struct NativeEditorBlockRow: View { NativeEditorRichBlockPreviewView( block: block, tableActions: tableActions, - richBlockActions: richBlockActions, + richBlockActions: activeRichBlockActions, pageID: pageID, - spaceID: spaceID + spaceID: spaceID, + serverURLString: serverURLString ) .frame(maxWidth: .infinity, alignment: .leading) } @@ -94,7 +101,7 @@ struct NativeEditorBlockRow: View { .foregroundStyle(.secondary) } } - .padding(.vertical, 2) + .padding(.vertical, rowVerticalPadding) .padding(.leading, blockIndentPadding) .background { (isSelected ? DocmostlyTheme.primaryTint : Color.clear) @@ -149,7 +156,7 @@ struct NativeEditorBlockRow: View { private var hasVisiblePrefix: Bool { switch block.kind { - case .bulletListItem, .orderedListItem, .taskListItem, .blockquote, .codeBlock, .unsupported: + case .bulletListItem, .orderedListItem, .taskListItem, .unsupported: true default: false @@ -159,4 +166,19 @@ struct NativeEditorBlockRow: View { private var blockIndentPadding: CGFloat { CGFloat(block.indentLevel) * 22 } + + private var rowVerticalPadding: CGFloat { + switch block.kind { + case .bulletListItem, .orderedListItem, .taskListItem: + 0 + case .divider: + 6 + default: + 2 + } + } + + private var activeRichBlockActions: NativeEditorRichBlockEditingActions? { + showsControls ? richBlockActions : nil + } } diff --git a/docmostly/Features/Editor/NativeEditorBlockSurfaces.swift b/docmostly/Features/Editor/NativeEditorBlockSurfaces.swift new file mode 100644 index 0000000..3ae3285 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorBlockSurfaces.swift @@ -0,0 +1,505 @@ +import SwiftUI + +struct NativeEditorBlockTextSurface: View { + let kind: NativeEditorBlockKind + let content: Content + + init(kind: NativeEditorBlockKind, @ViewBuilder content: () -> Content) { + self.kind = kind + self.content = content() + } + + var body: some View { + switch kind { + case .blockquote: + HStack(alignment: .top, spacing: 12) { + RoundedRectangle(cornerRadius: 1.5) + .fill(.tertiary) + .frame(width: 3) + + content + .foregroundStyle(.secondary) + .opacity(0.78) + .padding(.vertical, 2) + } + .fixedSize(horizontal: false, vertical: true) + case .codeBlock(let language): + VStack(alignment: .leading, spacing: 8) { + if let language, language.isEmpty == false { + Text(language) + .font(.caption) + .foregroundStyle(.secondary) + .textCase(.uppercase) + } + + content + .font(.body.monospaced()) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.28), in: .rect(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(.quaternary, lineWidth: 1) + } + default: + content + } + } +} + +struct NativeEditorHorizontalRuleView: View { + var body: some View { + Rectangle() + .fill(.quaternary) + .frame(height: 1) + .frame(maxWidth: .infinity) + .accessibilityLabel("Divider") + } +} + +struct NativeEditorCalloutBlockView: View { + let blockID: UUID + let callout: NativeEditorCalloutBlock + let actions: NativeEditorRichBlockEditingActions? + + var body: some View { + let presentation = NativeEditorCalloutPresentation(style: callout.style) + + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 10) { + NativeEditorCalloutIcon(presentation: presentation, customIcon: callout.icon) + .padding(.top, 1) + + Text(callout.previewText.isEmpty ? "Callout" : callout.previewText) + .font(.body) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let actions { + NativeEditorCalloutEditor(blockID: blockID, callout: callout, actions: actions) + .padding(.top, 2) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(presentation.tint.opacity(0.14), in: .rect(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(presentation.tint.opacity(0.28), lineWidth: 1) + } + .glassEffect(.regular.tint(presentation.tint.opacity(0.16)), in: .rect(cornerRadius: 10)) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(presentation.title) callout") + } +} + +struct NativeEditorDetailsBlockView: View { + let blockID: UUID + let details: NativeEditorDetailsBlock + let actions: NativeEditorRichBlockEditingActions? + @State private var transientIsOpen: Bool + + init(blockID: UUID, details: NativeEditorDetailsBlock, actions: NativeEditorRichBlockEditingActions?) { + self.blockID = blockID + self.details = details + self.actions = actions + _transientIsOpen = State(initialValue: details.isOpen) + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Button(action: toggleOpen) { + HStack(spacing: 8) { + Image(systemName: isOpen ? "chevron.down.circle" : "chevron.right.circle") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + + Text(details.summary.isEmpty ? "Toggle block" : details.summary) + .font(.body) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityLabel(isOpen ? "Collapse toggle block" : "Expand toggle block") + + if isOpen { + if details.previewText.isEmpty == false { + Text(details.previewText) + .font(.body) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let actions { + NativeEditorDetailsEditor(blockID: blockID, details: details, actions: actions) + .padding(.top, 2) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .contain) + } + + private var isOpen: Bool { + actions == nil ? transientIsOpen : details.isOpen + } + + private func toggleOpen() { + if let actions { + actions.updateDetails(blockID, details.summary, details.previewText, details.isOpen == false) + } else { + transientIsOpen.toggle() + } + } +} + +struct NativeEditorColumnsBlockView: View { + let blockID: UUID + let columns: NativeEditorColumnsBlock + let actions: NativeEditorRichBlockEditingActions? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: 10) { + ForEach(columns.normalizedColumnTexts.enumerated(), id: \.offset) { item in + NativeEditorColumnPreview(text: item.element) + .frame(minWidth: 180, maxWidth: .infinity, alignment: .topLeading) + } + } + + VStack(alignment: .leading, spacing: 10) { + ForEach(columns.normalizedColumnTexts.enumerated(), id: \.offset) { item in + NativeEditorColumnPreview(text: item.element) + } + } + } + + if let actions { + NativeEditorColumnsEditor(blockID: blockID, columns: columns, actions: actions) + .padding(.top, 2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .contain) + .accessibilityLabel("Columns") + } + +} + +private struct NativeEditorColumnPreview: View { + let text: String + + var body: some View { + Text(text.isEmpty ? "Empty column" : text) + .font(.body) + .foregroundStyle(text.isEmpty ? .secondary : .primary) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct NativeEditorDiagramBlockView: View { + let blockID: UUID + let diagram: NativeEditorDiagramBlock + let title: String + let systemImage: String + let serverURLString: String? + let update: ((UUID, String, String, String) -> Void)? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + if let sourceURL { + NativeEditorWebEmbedView( + html: NativeEditorWebEmbedHTML.imageHTML(source: sourceURL, title: displayTitle) + ) + .frame(minHeight: 260) + .clipShape(.rect(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(.quaternary, lineWidth: 1) + } + } else { + HStack(alignment: .top, spacing: 10) { + Image(systemName: systemImage) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + .frame(width: 28) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 4) { + Text(displayTitle) + .font(.headline) + .foregroundStyle(.primary) + + if let subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.18), in: .rect(cornerRadius: 10)) + } + + if let update { + NativeEditorDiagramEditor( + blockID: blockID, + diagram: diagram, + update: update + ) + .padding(.top, 2) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.12), in: .rect(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(.quaternary, lineWidth: 1) + } + .glassEffect(.regular.tint(.white.opacity(0.06)), in: .rect(cornerRadius: 10)) + .accessibilityElement(children: .contain) + .accessibilityLabel(displayTitle) + } + + private var displayTitle: String { + diagram.title ?? title + } + + private var subtitle: String? { + diagram.alternativeText ?? diagram.source + } + + private var sourceURL: URL? { + guard let source = diagram.source?.trimmingCharacters(in: .whitespacesAndNewlines), + source.isEmpty == false + else { + return nil + } + + if let absoluteURL = URL(string: source), + absoluteURL.scheme?.isEmpty == false { + return absoluteURL + } + + guard let serverURLString, + let serverURL = URL(string: serverURLString) + else { + return nil + } + + return URL(string: source, relativeTo: serverURL)?.absoluteURL + } +} + +struct NativeEditorMathBlockView: View { + let blockID: UUID + let math: NativeEditorMathBlock + let actions: NativeEditorRichBlockEditingActions? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Spacer(minLength: 0) + NativeEditorEquationText(expression: math.text.isEmpty ? "Equation" : math.text) + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.vertical, 4) + .frame(maxWidth: .infinity) + + if let actions { + NativeEditorMathBlockEditor(blockID: blockID, math: math, actions: actions) + .padding(.top, 2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel("Math equation \(math.text)") + } +} + +private struct NativeEditorEquationText: View { + let expression: String + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 1) { + ForEach(NativeEditorEquationPiece.pieces(from: expression)) { piece in + Text(piece.text) + .font(piece.kind == .main ? .title3 : .caption) + .fontDesign(.serif) + .italic() + .baselineOffset(piece.kind.baselineOffset) + } + } + } +} + +private struct NativeEditorEquationPiece: Identifiable { + enum Kind { + case main + case superscript + case `subscript` + + var baselineOffset: CGFloat { + switch self { + case .main: + 0 + case .superscript: + 8 + case .subscript: + -4 + } + } + } + + let id = UUID() + let text: String + let kind: Kind + + static func pieces(from expression: String) -> [NativeEditorEquationPiece] { + var pieces: [NativeEditorEquationPiece] = [] + var mainText = "" + var index = expression.startIndex + + while index < expression.endIndex { + let character = expression[index] + if character == "^" || character == "_" { + appendMainText(&mainText, to: &pieces) + let nextIndex = expression.index(after: index) + let parsed = parsedScript(in: expression, startingAt: nextIndex) + if parsed.text.isEmpty == false { + pieces.append(NativeEditorEquationPiece( + text: parsed.text, + kind: character == "^" ? .superscript : .subscript + )) + } + index = parsed.endIndex + } else { + mainText.append(character) + index = expression.index(after: index) + } + } + + appendMainText(&mainText, to: &pieces) + return pieces.isEmpty ? [NativeEditorEquationPiece(text: expression, kind: .main)] : pieces + } + + private static func appendMainText( + _ mainText: inout String, + to pieces: inout [NativeEditorEquationPiece] + ) { + guard mainText.isEmpty == false else { return } + pieces.append(NativeEditorEquationPiece(text: mainText, kind: .main)) + mainText = "" + } + + private static func parsedScript( + in expression: String, + startingAt index: String.Index + ) -> (text: String, endIndex: String.Index) { + guard index < expression.endIndex else { + return ("", index) + } + + if expression[index] == "{" { + let contentStart = expression.index(after: index) + guard let closeIndex = expression[contentStart...].firstIndex(of: "}") else { + return ("", contentStart) + } + return (String(expression[contentStart...Binding var isAuthoringEnabled = true + var serverURLString: String? var importAttachment: (NativeEditorAttachmentImportKind) -> Void = { _ in } var applyCommand: ((NativeEditorCommand) -> Void)? var body: some View { - LazyVStack(alignment: .leading, spacing: 10) { + LazyVStack(alignment: .leading, spacing: 6) { TextField("Page title", text: $viewModel.title, axis: .vertical) .font(.largeTitle) .bold() @@ -51,6 +52,7 @@ struct NativeEditorBodyView: View { richBlockActions: authoringIsAvailable ? richBlockEditingActions : nil, pageID: viewModel.currentPageID, spaceID: viewModel.currentSpaceID, + serverURLString: serverURLString, focusBlock: { guard authoringIsAvailable else { return } viewModel.focus(blockID: block.id) diff --git a/docmostly/Features/Editor/NativeEditorDebugPreviewView.swift b/docmostly/Features/Editor/NativeEditorDebugPreviewView.swift index 3398199..7fecb24 100644 --- a/docmostly/Features/Editor/NativeEditorDebugPreviewView.swift +++ b/docmostly/Features/Editor/NativeEditorDebugPreviewView.swift @@ -2,7 +2,7 @@ import SwiftUI #if DEBUG struct NativeEditorDebugPreviewView: View { - @State private var viewModel = NativeRichEditorViewModel(pageID: "preview", initialTitle: "Native Editor") + @State private var viewModel = NativeRichEditorViewModel(pageID: "preview", initialTitle: "Blah blah") @FocusState private var focusedField: NativeEditorFocus? var body: some View { @@ -37,43 +37,7 @@ struct NativeEditorDebugPreviewView: View { private func configurePreview() { guard viewModel.document.blocks.first?.text.characters.isEmpty == true else { return } - var paragraph = AttributedString( - "Select text, then use the toolbar for bold, italic, lists, links, and alignment." - ) - paragraph.inlinePresentationIntent = .emphasized - - viewModel.document = NativeEditorDocument(blocks: [ - NativeEditorBlock( - kind: .heading(level: 1), - text: AttributedString("Native Docmost editor"), - alignment: .left - ), - NativeEditorBlock(kind: .paragraph, text: paragraph, alignment: .left), - NativeEditorBlock( - kind: .bulletListItem, - text: AttributedString("ProseMirror JSON bridge"), - alignment: .left - ), - NativeEditorBlock( - kind: .bulletListItem, - text: AttributedString("Keyboard toolbar"), - alignment: .left - ), - NativeEditorBlock( - kind: .taskListItem(isChecked: false), - text: AttributedString("Autosave through pages/update"), - alignment: .left - ), - NativeEditorBlock( - kind: .paragraph, - text: AttributedString("/to"), - alignment: .left - ) - ]) - if let commandBlockID = viewModel.document.blocks.last?.id { - viewModel.focus(blockID: commandBlockID) - focusedField = .block(commandBlockID) - } + viewModel.document = NativeEditorDocument(blocks: Self.previewBlocks) viewModel.resetEditingHistory() } @@ -87,5 +51,86 @@ struct NativeEditorDebugPreviewView: View { viewModel.clearFocus() } } + + private static let previewBlocks = [ + NativeEditorBlock( + kind: .paragraph, + text: AttributedString("The quick brown fox jumped over the fence or whatever"), + alignment: .left + ), + NativeEditorBlock(kind: .heading(level: 1), text: AttributedString("Heading 1"), alignment: .left), + NativeEditorBlock(kind: .heading(level: 2), text: AttributedString("Heading 2"), alignment: .left), + NativeEditorBlock(kind: .heading(level: 3), text: AttributedString("Heading 3"), alignment: .left), + NativeEditorBlock( + kind: .bulletListItem, + text: AttributedString("Bullet 1"), + alignment: .left + ), + NativeEditorBlock( + kind: .bulletListItem, + text: AttributedString("Bullet 1"), + alignment: .left + ), + NativeEditorBlock(kind: .bulletListItem, text: AttributedString("Bullet 3"), alignment: .left), + NativeEditorBlock( + kind: .orderedListItem(ordinal: 1), + text: AttributedString("One"), + alignment: .left + ), + NativeEditorBlock( + kind: .orderedListItem(ordinal: 2), + text: AttributedString("Two"), + alignment: .left + ), + NativeEditorBlock( + kind: .orderedListItem(ordinal: 3), + text: AttributedString("Three"), + alignment: .left + ), + NativeEditorBlock( + kind: .taskListItem(isChecked: false), + text: AttributedString("Todo 1"), + alignment: .left + ), + NativeEditorBlock( + kind: .taskListItem(isChecked: false), + text: AttributedString("Todo 2"), + alignment: .left + ), + NativeEditorBlock(kind: .blockquote, text: AttributedString("The quick maniac man"), alignment: .left), + NativeEditorBlock(kind: .divider, text: AttributedString("Divider"), alignment: .left), + NativeEditorBlock( + kind: .codeBlock(language: "swift"), + text: AttributedString(#"print("Hello World!")"#), + alignment: .left + ), + NativeEditorBlock( + kind: .details(NativeEditorDetailsBlock( + summary: "Toggle block", + previewText: "Can you see everything here?", + isOpen: false + )), + text: AttributedString("Toggle block"), + alignment: .left + ), + NativeEditorBlock( + kind: .details(NativeEditorDetailsBlock( + summary: "Open toggle block", + previewText: "Can you see everything here?", + isOpen: true + )), + text: AttributedString("Open toggle block"), + alignment: .left + ), + NativeEditorBlock( + kind: .callout(NativeEditorCalloutBlock( + style: "danger", + icon: nil, + previewText: "Hey you better watch out!!!!" + )), + text: AttributedString("Hey you better watch out!!!!"), + alignment: .left + ) + ] } #endif diff --git a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift index 32e11e3..bf3c649 100644 --- a/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift +++ b/docmostly/Features/Editor/NativeEditorDocument+Payloads.swift @@ -110,9 +110,13 @@ nonisolated extension NativeEditorDocument { } static func embedBlock(from node: ProseMirrorNode) -> NativeEditorEmbedBlock { - NativeEditorEmbedBlock( - source: node.attrs?["src"]?.stringValue, - provider: node.attrs?["provider"]?.stringValue ?? youtubeProviderName(for: node), + let source = node.attrs?["src"]?.stringValue + return NativeEditorEmbedBlock( + source: source, + provider: NativeEditorEmbedResolver.provider( + source: source, + explicitProvider: node.attrs?["provider"]?.stringValue ?? youtubeProviderName(for: node) + ), alignment: node.attrs?["align"]?.stringValue, width: node.attrs?["width"]?.displayString, height: node.attrs?["height"]?.displayString diff --git a/docmostly/Features/Editor/NativeEditorEmbedBlockView.swift b/docmostly/Features/Editor/NativeEditorEmbedBlockView.swift new file mode 100644 index 0000000..2bd697e --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorEmbedBlockView.swift @@ -0,0 +1,254 @@ +import SwiftUI +import WebKit +import YouTubePlayerKit + +struct NativeEditorEmbedBlockView: View { + let blockID: UUID + let embed: NativeEditorEmbedBlock + let actions: NativeEditorRichBlockEditingActions? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + if let youtubeSource = embed.youtubePlayerSource { + NativeEditorYouTubeEmbedView(source: youtubeSource) + } else if let spotifyURL = embed.spotifyEmbedURL { + NativeEditorSpotifyEmbedView(url: spotifyURL) + } else { + NativeEditorGenericEmbedView(embed: embed) + } + + if let actions { + NativeEditorEmbedEditor(blockID: blockID, embed: embed, actions: actions) + .padding(.top, 2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .contain) + } +} + +private struct NativeEditorYouTubeEmbedView: View { + let source: String + @State private var player: YouTubePlayer + + init(source: String) { + self.source = source + self._player = State(initialValue: YouTubePlayer(urlString: source)) + } + + var body: some View { + YouTubePlayerView(player) { state in + switch state { + case .idle: + ProgressView() + .controlSize(.small) + case .ready: + EmptyView() + case .error: + Label("Unable to load YouTube video", systemImage: "exclamationmark.triangle") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .frame(minHeight: 220) + .aspectRatio(16.0 / 9.0, contentMode: .fit) + .clipShape(.rect(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(.quaternary, lineWidth: 1) + } + .accessibilityLabel("YouTube player") + } +} + +private struct NativeEditorSpotifyEmbedView: View { + let url: URL + + var body: some View { + NativeEditorWebEmbedView( + html: NativeEditorWebEmbedHTML.iframeHTML(source: url, title: "Spotify embed") + ) + .frame(minHeight: 180) + .clipShape(.rect(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(.quaternary, lineWidth: 1) + } + .accessibilityLabel("Spotify player") + } +} + +private struct NativeEditorGenericEmbedView: View { + let embed: NativeEditorEmbedBlock + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "rectangle.connected.to.line.below") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + .frame(width: 28) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 4) { + Text(embed.displayProvider) + .font(.headline) + .foregroundStyle(.primary) + + if let sourceURL { + Link(sourceURL.absoluteString, destination: sourceURL) + .font(.subheadline) + .lineLimit(3) + } else if let source = embed.source, source.isEmpty == false { + Text(source) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.18), in: .rect(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(.quaternary, lineWidth: 1) + } + .glassEffect(.regular.tint(.white.opacity(0.08)), in: .rect(cornerRadius: 10)) + } + + private var sourceURL: URL? { + guard let source = embed.source else { return nil } + return URL(string: source) + } +} + +enum NativeEditorWebEmbedHTML { + static func iframeHTML(source: URL, title: String) -> String { + let escapedSource = escapedAttribute(source.absoluteString) + let escapedTitle = escapedAttribute(title) + return """ + + + + + + + + + + + """ + } + + static func imageHTML(source: URL, title: String) -> String { + """ + + + + + + + + \(escapedAttribute(title)) + + + """ + } + + private static func escapedAttribute(_ value: String) -> String { + value + .replacing("&", with: "&") + .replacing("\"", with: """) + .replacing("<", with: "<") + .replacing(">", with: ">") + } +} + +#if os(iOS) +struct NativeEditorWebEmbedView: UIViewRepresentable { + let html: String + + func makeCoordinator() -> NativeEditorWebEmbedCoordinator { + NativeEditorWebEmbedCoordinator() + } + + func makeUIView(context: Context) -> WKWebView { + let webView = WKWebView() + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.isScrollEnabled = false + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + context.coordinator.load(html: html, in: webView) + } +} +#elseif os(macOS) +struct NativeEditorWebEmbedView: NSViewRepresentable { + let html: String + + func makeCoordinator() -> NativeEditorWebEmbedCoordinator { + NativeEditorWebEmbedCoordinator() + } + + func makeNSView(context: Context) -> WKWebView { + let webView = WKWebView() + webView.setValue(false, forKey: "drawsBackground") + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + context.coordinator.load(html: html, in: webView) + } +} +#endif + +final class NativeEditorWebEmbedCoordinator { + private var loadedHTML: String? + + func load(html: String, in webView: WKWebView) { + guard loadedHTML != html else { return } + loadedHTML = html + webView.loadHTMLString(html, baseURL: nil) + } +} diff --git a/docmostly/Features/Editor/NativeEditorEmbedPresentation.swift b/docmostly/Features/Editor/NativeEditorEmbedPresentation.swift new file mode 100644 index 0000000..9ebdba6 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorEmbedPresentation.swift @@ -0,0 +1,100 @@ +import Foundation + +nonisolated extension NativeEditorEmbedBlock { + var displayProvider: String { + NativeEditorEmbedResolver.provider(source: source, explicitProvider: provider) + } + + var youtubePlayerSource: String? { + NativeEditorEmbedResolver.youtubeWatchSource(from: source) + } + + var spotifyEmbedURL: URL? { + NativeEditorEmbedResolver.spotifyEmbedURL(from: source) + } +} + +nonisolated enum NativeEditorEmbedResolver { + static func provider(source: String?, explicitProvider: String?) -> String { + if let sourceProvider = inferredProvider(from: source) { + return sourceProvider + } + + let trimmedProvider = explicitProvider?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedProvider?.isEmpty == false ? trimmedProvider ?? "Embed" : "Embed" + } + + static func youtubeWatchSource(from source: String?) -> String? { + guard let videoID = youtubeVideoID(from: source) else { return nil } + return "https://www.youtube.com/watch?v=\(videoID)" + } + + static func spotifyEmbedURL(from source: String?) -> URL? { + guard + let source, + var components = URLComponents(string: source), + let host = components.host?.lowercased(), + host == "open.spotify.com" || host == "www.open.spotify.com" + else { + return nil + } + + components.scheme = "https" + components.host = "open.spotify.com" + + let pathComponents = components.path.split(separator: "/").map(String.init) + guard pathComponents.count >= 2 else { return nil } + + if pathComponents.first != "embed" { + components.path = "/embed/" + pathComponents.prefix(2).joined(separator: "/") + } + + return components.url + } + + private static func inferredProvider(from source: String?) -> String? { + if youtubeVideoID(from: source) != nil { + return "YouTube" + } + + if spotifyEmbedURL(from: source) != nil { + return "Spotify" + } + + return nil + } + + private static func youtubeVideoID(from source: String?) -> String? { + guard + let source, + let components = URLComponents(string: source), + let host = components.host?.lowercased() + else { + return nil + } + + let normalizedHost = host.hasPrefix("www.") ? String(host.dropFirst(4)) : host + if normalizedHost == "youtu.be" { + return nonEmptyVideoID(String(components.path.dropFirst())) + } + + guard normalizedHost == "youtube.com" || normalizedHost == "youtube-nocookie.com" else { + return nil + } + + if components.path.hasPrefix("/embed/") { + return nonEmptyVideoID(String(components.path.dropFirst("/embed/".count))) + } + + if components.path == "/watch" { + return components.queryItems?.first { $0.name == "v" }?.value.flatMap(nonEmptyVideoID) + } + + return nil + } + + private static func nonEmptyVideoID(_ value: String) -> String? { + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedValue.isEmpty ? nil : trimmedValue + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift index ac4d804..cbe6ced 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Embeds.swift @@ -338,9 +338,13 @@ extension NativeEditorMarkdownParser { from attributes: [String: String], linkAttributes: [String: String] ) -> NativeEditorEmbedBlock { - NativeEditorEmbedBlock( - source: nonEmptyHTMLAttribute(attributes["data-src"]) ?? nonEmptyHTMLAttribute(linkAttributes["href"]), - provider: nonEmptyHTMLAttribute(attributes["data-provider"]), + let source = nonEmptyHTMLAttribute(attributes["data-src"]) ?? nonEmptyHTMLAttribute(linkAttributes["href"]) + return NativeEditorEmbedBlock( + source: source, + provider: NativeEditorEmbedResolver.provider( + source: source, + explicitProvider: nonEmptyHTMLAttribute(attributes["data-provider"]) + ), alignment: nonEmptyHTMLAttribute(attributes["data-align"]), width: nonEmptyHTMLAttribute(attributes["data-width"]), height: nonEmptyHTMLAttribute(attributes["data-height"]) diff --git a/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift b/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift index 0e040d1..32e85f5 100644 --- a/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift +++ b/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift @@ -6,6 +6,7 @@ struct NativeEditorRichBlockPreviewView: View { var richBlockActions: NativeEditorRichBlockEditingActions? let pageID: String let spaceID: String? + var serverURLString: String? var body: some View { switch block.kind { @@ -19,9 +20,7 @@ struct NativeEditorRichBlockPreviewView: View { Divider() } case .divider: - Divider() - .padding(.vertical) - .accessibilityLabel("Divider") + NativeEditorHorizontalRuleView() case .table(let table): if let tableActions { NativeEditorTableEditor(blockID: block.id, table: table, actions: tableActions) @@ -75,35 +74,11 @@ struct NativeEditorRichBlockPreviewView: View { } } case .callout(let callout): - previewShell( - systemImage: calloutSystemImage(for: callout.style), - title: "\(callout.style.capitalized) callout", - subtitle: callout.previewText - ) { - if let richBlockActions { - NativeEditorCalloutEditor(blockID: block.id, callout: callout, actions: richBlockActions) - } - } + NativeEditorCalloutBlockView(blockID: block.id, callout: callout, actions: richBlockActions) case .details(let details): - previewShell( - systemImage: details.isOpen ? "chevron.down.circle" : "chevron.right.circle", - title: details.summary.isEmpty ? "Toggle block" : details.summary, - subtitle: details.previewText - ) { - if let richBlockActions { - NativeEditorDetailsEditor(blockID: block.id, details: details, actions: richBlockActions) - } - } + NativeEditorDetailsBlockView(blockID: block.id, details: details, actions: richBlockActions) case .columns(let columns): - previewShell( - systemImage: columnsSystemImage(for: columns.columnCount), - title: "Columns", - subtitle: columns.previewText - ) { - if let richBlockActions { - NativeEditorColumnsEditor(blockID: block.id, columns: columns, actions: richBlockActions) - } - } + NativeEditorColumnsBlockView(blockID: block.id, columns: columns, actions: richBlockActions) case .subpages: previewShell(systemImage: "doc.on.doc", title: "Subpages", subtitle: nil) { NativeEditorSubpagesView(pageID: pageID, spaceID: spaceID) @@ -139,49 +114,27 @@ struct NativeEditorRichBlockPreviewView: View { subtitle: base.pageID ?? "Base page pending" ) case .embed(let embed): - previewShell( - systemImage: "rectangle.connected.to.line.below", - title: embed.provider ?? "Embed", - subtitle: embed.source - ) { - if let richBlockActions { - NativeEditorEmbedEditor(blockID: block.id, embed: embed, actions: richBlockActions) - } - } + NativeEditorEmbedBlockView(blockID: block.id, embed: embed, actions: richBlockActions) case .drawio(let diagram): - previewShell( + NativeEditorDiagramBlockView( + blockID: block.id, + diagram: diagram, + title: "Draw.io diagram", systemImage: "flowchart", - title: diagram.title ?? "Draw.io diagram", - subtitle: diagram.alternativeText ?? diagram.source - ) { - if let richBlockActions { - NativeEditorDiagramEditor( - blockID: block.id, - diagram: diagram, - update: richBlockActions.updateDrawio - ) - } - } + serverURLString: serverURLString, + update: richBlockActions?.updateDrawio + ) case .excalidraw(let diagram): - previewShell( + NativeEditorDiagramBlockView( + blockID: block.id, + diagram: diagram, + title: "Excalidraw diagram", systemImage: "scribble.variable", - title: diagram.title ?? "Excalidraw diagram", - subtitle: diagram.alternativeText ?? diagram.source - ) { - if let richBlockActions { - NativeEditorDiagramEditor( - blockID: block.id, - diagram: diagram, - update: richBlockActions.updateExcalidraw - ) - } - } + serverURLString: serverURLString, + update: richBlockActions?.updateExcalidraw + ) case .mathBlock(let math): - previewShell(systemImage: "function", title: "Math equation", subtitle: math.text) { - if let richBlockActions { - NativeEditorMathBlockEditor(blockID: block.id, math: math, actions: richBlockActions) - } - } + NativeEditorMathBlockView(blockID: block.id, math: math, actions: richBlockActions) case .unsupported: NativeEditorUnsupportedBlockView(block: block) case .paragraph, .heading, .bulletListItem, .orderedListItem, .taskListItem, .blockquote, .codeBlock: @@ -223,11 +176,12 @@ struct NativeEditorRichBlockPreviewView: View { } .padding() .frame(maxWidth: .infinity, alignment: .leading) - .background(.quaternary.opacity(0.24), in: .rect(cornerRadius: 8)) + .background(.quaternary.opacity(0.18), in: .rect(cornerRadius: 10)) .overlay { - RoundedRectangle(cornerRadius: 8) + RoundedRectangle(cornerRadius: 10) .stroke(.quaternary, lineWidth: 1) } + .glassEffect(.regular.tint(.white.opacity(0.08)), in: .rect(cornerRadius: 10)) .accessibilityElement(children: .combine) .accessibilityLabel(block.kind.accessibilityLabel) } @@ -237,29 +191,4 @@ struct NativeEditorRichBlockPreviewView: View { return ByteCountFormatStyle(style: .file).format(Int64(size)) } - private func calloutSystemImage(for style: String) -> String { - switch style { - case "note": - "note.text" - case "warning": - "exclamationmark.triangle" - case "success": - "checkmark.circle" - case "danger", "error": - "xmark.octagon" - case "tip": - "lightbulb" - default: - "info.circle" - } - } - - private func columnsSystemImage(for columnCount: Int) -> String { - switch columnCount { - case 3...: - "rectangle.split.3x1" - default: - "rectangle.split.2x1" - } - } } diff --git a/docmostly/Features/Editor/NativeEditorRichBlockPropertyEditors.swift b/docmostly/Features/Editor/NativeEditorRichBlockPropertyEditors.swift index 7dcdcb1..69acdf1 100644 --- a/docmostly/Features/Editor/NativeEditorRichBlockPropertyEditors.swift +++ b/docmostly/Features/Editor/NativeEditorRichBlockPropertyEditors.swift @@ -53,9 +53,6 @@ struct NativeEditorDetailsEditor: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Toggle("Open", isOn: openBinding) - .toggleStyle(.switch) - TextField("Summary", text: summaryBinding, axis: .vertical) .textFieldStyle(.roundedBorder) .lineLimit(1...3) @@ -66,14 +63,6 @@ struct NativeEditorDetailsEditor: View { } } - private var openBinding: Binding { - Binding { - details.isOpen - } set: { isOpen in - actions.updateDetails(blockID, details.summary, details.previewText, isOpen) - } - } - private var summaryBinding: Binding { Binding { details.summary diff --git a/docmostly/Features/Editor/NativeEditorSlashCommandMenu.swift b/docmostly/Features/Editor/NativeEditorSlashCommandMenu.swift index a24715e..827982f 100644 --- a/docmostly/Features/Editor/NativeEditorSlashCommandMenu.swift +++ b/docmostly/Features/Editor/NativeEditorSlashCommandMenu.swift @@ -17,44 +17,60 @@ struct NativeEditorSlashCommandMenu: View { .padding(.top, 10) .padding(.bottom, 4) - if commands.isEmpty { - Text("No matching commands") - .foregroundStyle(.secondary) - .padding() - } else { - ForEach(commands) { command in - Button { - if let importKind = command.attachmentImportKind { - importAttachment(importKind) - } else if let applyCommand { - applyCommand(command) - } else { - viewModel.applySlashCommand(command) - } - } label: { - HStack(spacing: 10) { - Image(systemName: command.systemImage) - .frame(width: 24) - .foregroundStyle(DocmostlyTheme.primary) - - VStack(alignment: .leading, spacing: 2) { - Text(command.title) - Text(command.subtitle) - .font(.caption) - .foregroundStyle(.secondary) + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + if commands.isEmpty { + Text("No matching commands") + .foregroundStyle(.secondary) + .padding() + } else { + ForEach(commands) { command in + NativeEditorSlashCommandRow(command: command) { + if let importKind = command.attachmentImportKind { + importAttachment(importKind) + } else if let applyCommand { + applyCommand(command) + } else { + viewModel.applySlashCommand(command) + } } - - Spacer() } - .contentShape(.rect) - .padding(.horizontal) - .padding(.vertical, 9) } - .buttonStyle(.plain) } + .frame(maxWidth: .infinity, alignment: .leading) } + .frame(maxHeight: 320) } .frame(maxWidth: 380, alignment: .leading) } } } + +private struct NativeEditorSlashCommandRow: View { + let command: NativeEditorCommand + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 10) { + Image(systemName: command.systemImage) + .frame(width: 24) + .foregroundStyle(DocmostlyTheme.primary) + + VStack(alignment: .leading, spacing: 2) { + Text(command.title) + Text(command.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + } + .contentShape(.rect) + .padding(.horizontal) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + } +} diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel.swift b/docmostly/Features/Editor/NativeRichEditorViewModel.swift index 15a74cd..bedcf90 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel.swift @@ -142,7 +142,8 @@ final class NativeRichEditorViewModel { title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } - func load(appState: AppState) async { + @discardableResult + func load(appState: AppState) async -> Bool { isLoading = true errorMessage = nil defer { isLoading = false } @@ -161,8 +162,13 @@ final class NativeRichEditorViewModel { markRemoteBaseline(updatedAt: page.updatedAt) applyPagePermissions(page.permissions) isDirty = false + return true } catch { + guard Self.isCancelledLoadError(error) == false else { + return false + } errorMessage = error.localizedDescription + return false } } @@ -217,6 +223,19 @@ final class NativeRichEditorViewModel { } } + static func isCancelledLoadError(_ error: any Error) -> Bool { + if error is CancellationError { + return true + } + + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled { + return true + } + + return false + } + func focusTitle() { guard canEdit else { clearAuthoringState() diff --git a/docmostly/Features/PageReader/PageHistoryDetailView.swift b/docmostly/Features/PageReader/PageHistoryDetailView.swift index 96add8b..25fbcd5 100644 --- a/docmostly/Features/PageReader/PageHistoryDetailView.swift +++ b/docmostly/Features/PageReader/PageHistoryDetailView.swift @@ -33,7 +33,12 @@ struct PageHistoryDetailView: View { VStack(alignment: .leading, spacing: 12) { ForEach(viewModel.selectedDocument.blocks, id: \.id) { block in - NativeEditorRichBlockPreviewView(block: block, pageID: pageID, spaceID: spaceID) + NativeEditorRichBlockPreviewView( + block: block, + pageID: pageID, + spaceID: spaceID, + serverURLString: appState.serverURLString + ) } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/docmostly/Features/PageReader/PageReaderView+Actions.swift b/docmostly/Features/PageReader/PageReaderView+Actions.swift index 2036446..f8d8985 100644 --- a/docmostly/Features/PageReader/PageReaderView+Actions.swift +++ b/docmostly/Features/PageReader/PageReaderView+Actions.swift @@ -12,38 +12,60 @@ extension PageReaderView { editorFocusedField = nil realtimePageID = nil editorViewModel = nil - let editorViewModel = NativeRichEditorViewModel(pageID: pageID) - await editorViewModel.load(appState: appState) - guard Task.isCancelled == false else { return } + for attempt in 0..<2 { + let editorViewModel = NativeRichEditorViewModel(pageID: pageID, initialTitle: initialTitle ?? "") + let didLoadPage = await editorViewModel.load(appState: appState) + guard Task.isCancelled == false else { return } - if editorViewModel.errorMessage == nil { - self.editorViewModel = editorViewModel - if let currentSpaceID = editorViewModel.currentSpaceID { - pageLoaded(editorViewModel.currentPageSlugID, currentSpaceID, editorViewModel.title) + if didLoadPage { + await finishNativePageLoad(editorViewModel) + return } - if editorViewModel.canEdit == false { - readerMode = .read + + if editorViewModel.errorMessage != nil { + self.editorViewModel = editorViewModel + return } - async let attachCRDT: Void = NativeEditorCRDTDocumentEngineAttachment.attachIfAvailable( - to: editorViewModel, - appState: appState - ) - async let loadCompanions: Void = viewModel.loadCompanions( - pageID: editorViewModel.currentPageID, - appState: appState - ) - await attachCRDT - guard Task.isCancelled == false else { - await loadCompanions + guard attempt == 0 else { + editorViewModel.errorMessage = "Page loading was interrupted." + self.editorViewModel = editorViewModel + return + } + + do { + try await Task.sleep(for: .milliseconds(250)) + } catch { return } - realtimePageID = editorViewModel.currentPageID + } + } + + private func finishNativePageLoad(_ editorViewModel: NativeRichEditorViewModel) async { + self.editorViewModel = editorViewModel + if let currentSpaceID = editorViewModel.currentSpaceID { + pageLoaded(editorViewModel.currentPageSlugID, currentSpaceID, editorViewModel.title) + } + if editorViewModel.canEdit == false { + readerMode = .read + } + + async let attachCRDT: Void = NativeEditorCRDTDocumentEngineAttachment.attachIfAvailable( + to: editorViewModel, + appState: appState + ) + async let loadCompanions: Void = viewModel.loadCompanions( + pageID: editorViewModel.currentPageID, + appState: appState + ) + await attachCRDT + guard Task.isCancelled == false else { await loadCompanions - } else { - self.editorViewModel = editorViewModel + return } + realtimePageID = editorViewModel.currentPageID + await loadCompanions } func autosaveInlineEdits() { diff --git a/docmostly/Features/PageReader/PageReaderView.swift b/docmostly/Features/PageReader/PageReaderView.swift index e8e7eac..ed5dfc6 100644 --- a/docmostly/Features/PageReader/PageReaderView.swift +++ b/docmostly/Features/PageReader/PageReaderView.swift @@ -74,6 +74,7 @@ struct PageReaderView: View { viewModel: editorViewModel, focusedField: $editorFocusedField, isAuthoringEnabled: readerMode == .edit, + serverURLString: appState.serverURLString, importAttachment: beginAttachmentImport, applyCommand: applyEditorCommand ) @@ -92,7 +93,9 @@ struct PageReaderView: View { .scrollTargetLayout() } .scrollPosition($scrollPosition) + #if os(iOS) .safeAreaPadding(.bottom, 72) + #endif .navigationTitle(navigationChromeTitle) #if os(iOS) .navigationBarTitleDisplayMode(.inline) @@ -100,6 +103,7 @@ struct PageReaderView: View { .toolbar { pageReaderToolbar } + #if os(iOS) .safeAreaInset(edge: .bottom) { if let editorViewModel, readerMode == .edit, editorViewModel.isEditing, editorViewModel.canEdit { VStack(spacing: 6) { @@ -127,6 +131,7 @@ struct PageReaderView: View { } } } + #endif .fileImporter( isPresented: $isShowingAttachmentImporter, allowedContentTypes: attachmentAllowedContentTypes, diff --git a/docmostly/Features/Spaces/MainShellView.swift b/docmostly/Features/Spaces/MainShellView.swift index 90a9eb6..b3b4281 100644 --- a/docmostly/Features/Spaces/MainShellView.swift +++ b/docmostly/Features/Spaces/MainShellView.swift @@ -13,7 +13,7 @@ struct MainShellView: View { } .navigationSplitViewStyle(.balanced) .task { - await appState.loadSpaces() + await loadSpacesIfNeeded() } #else NavigationSplitView(columnVisibility: $columnVisibility) { @@ -25,8 +25,13 @@ struct MainShellView: View { } .navigationSplitViewStyle(.balanced) .task { - await appState.loadSpaces() + await loadSpacesIfNeeded() } #endif } + + private func loadSpacesIfNeeded() async { + guard appState.spaces.isEmpty else { return } + await appState.loadSpaces() + } } diff --git a/docmostlyTests/Editor/NativeEditorEmbedPresentationTests.swift b/docmostlyTests/Editor/NativeEditorEmbedPresentationTests.swift new file mode 100644 index 0000000..6198775 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorEmbedPresentationTests.swift @@ -0,0 +1,99 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorEmbedPresentationTests { + @Test func decodesThreeEqualColumnsFromDocmostDocument() throws { + let document = NativeEditorDocument(proseMirrorDocument: ProseMirrorDocument(content: [ + ProseMirrorNode( + type: "columns", + attrs: ["layout": .string("three_equal")], + content: [ + columnNode("Columns 1"), + columnNode("Column 2"), + columnNode("Column 3") + ] + ) + ])) + + try #require(document.blocks.count == 1) + guard case .columns(let columns) = document.blocks[0].kind else { + Issue.record("Expected a native columns block.") + return + } + + #expect(columns.layout == "three_equal") + #expect(columns.columnCount == 3) + #expect(columns.normalizedColumnTexts == ["Columns 1", "Column 2", "Column 3"]) + } + + @Test func infersSpotifyProviderFromEmbedSource() throws { + let source = "https://open.spotify.com/embed/track/7FVZXcWUEkFOwMck2L04pr?utm_source=generator" + let document = NativeEditorDocument(proseMirrorDocument: ProseMirrorDocument(content: [ + ProseMirrorNode(type: "embed", attrs: [ + "src": .string(source), + "width": .int(800), + "height": .int(600) + ]) + ])) + + try #require(document.blocks.count == 1) + guard case .embed(let embed) = document.blocks[0].kind else { + Issue.record("Expected a native embed block.") + return + } + + #expect(embed.provider == "Spotify") + #expect(embed.displayProvider == "Spotify") + #expect(embed.spotifyEmbedURL?.absoluteString == source) + } + + @Test func canonicalizesStandardSpotifyShareURLToEmbedURL() { + let embed = NativeEditorEmbedBlock( + source: "https://open.spotify.com/track/7FVZXcWUEkFOwMck2L04pr?si=650fa2b296164db6", + provider: nil, + alignment: nil, + width: nil, + height: nil + ) + + #expect(embed.displayProvider == "Spotify") + #expect( + embed.spotifyEmbedURL?.absoluteString == + "https://open.spotify.com/embed/track/7FVZXcWUEkFOwMck2L04pr?si=650fa2b296164db6" + ) + } + + @Test func infersYouTubeProviderAndPlayerSourceFromNoCookieEmbed() throws { + let document = NativeEditorDocument(proseMirrorDocument: ProseMirrorDocument(content: [ + ProseMirrorNode(type: "embed", attrs: [ + "src": .string("https://www.youtube-nocookie.com/embed/DTUDPMmy6IU"), + "width": .int(800), + "height": .int(600) + ]) + ])) + + try #require(document.blocks.count == 1) + guard case .embed(let embed) = document.blocks[0].kind else { + Issue.record("Expected a native embed block.") + return + } + + #expect(embed.provider == "YouTube") + #expect(embed.displayProvider == "YouTube") + #expect(embed.youtubePlayerSource == "https://www.youtube.com/watch?v=DTUDPMmy6IU") + } + + private func columnNode(_ text: String) -> ProseMirrorNode { + ProseMirrorNode( + type: "column", + content: [ + ProseMirrorNode( + type: "paragraph", + content: [ProseMirrorNode(type: "text", text: text)] + ) + ] + ) + } +} diff --git a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift index c4ac3d1..0d8cf64 100644 --- a/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift +++ b/docmostlyTests/Editor/NativeRichEditorViewModelTests.swift @@ -5,6 +5,14 @@ import Testing @MainActor struct NativeRichEditorViewModelTests { + @Test func cancelledLoadErrorsAreNotFatalPageErrors() { + let urlCancellation = URLError(.cancelled) + + #expect(NativeRichEditorViewModel.isCancelledLoadError(CancellationError()) == true) + #expect(NativeRichEditorViewModel.isCancelledLoadError(urlCancellation) == true) + #expect(NativeRichEditorViewModel.isCancelledLoadError(APIError.connectionFailed("offline")) == false) + } + @Test func togglesInlineMarkAcrossActiveBlockWhenSelectionIsMissing() { let block = NativeEditorBlock(kind: .paragraph, text: AttributedString("Native editor"), alignment: .left) let viewModel = NativeRichEditorViewModel(pageID: "page-1", initialTitle: "Page") From 9ea1b705e60b22070c03d0d72e4dd752fe4c2de2 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Tue, 7 Jul 2026 23:01:40 +0100 Subject: [PATCH 2/6] fix: address native embed review findings --- docmostly.xcodeproj/project.pbxproj | 1 + .../Editor/NativeEditorBlockSurfaces.swift | 25 ++----- .../Editor/NativeEditorEmbedBlockView.swift | 37 ++++++++-- .../NativeEditorEmbedPresentation.swift | 49 +++++++++++-- .../Editor/NativeEditorWebURLPolicy.swift | 73 +++++++++++++++++++ .../Features/PageReader/PageReaderView.swift | 2 - docmostly/Features/Spaces/MainShellView.swift | 1 - .../NativeEditorEmbedPresentationTests.swift | 55 ++++++++++++++ 8 files changed, 208 insertions(+), 35 deletions(-) create mode 100644 docmostly/Features/Editor/NativeEditorWebURLPolicy.swift diff --git a/docmostly.xcodeproj/project.pbxproj b/docmostly.xcodeproj/project.pbxproj index b717a0b..5f38417 100644 --- a/docmostly.xcodeproj/project.pbxproj +++ b/docmostly.xcodeproj/project.pbxproj @@ -265,6 +265,7 @@ ); name = docmostly; packageProductDependencies = ( + E03B35CC5DAE0817D7846F6B /* YouTubePlayerKit */, ); productName = docmostly; productReference = 47D95DD22FE294E500ECEA87 /* docmostly.app */; diff --git a/docmostly/Features/Editor/NativeEditorBlockSurfaces.swift b/docmostly/Features/Editor/NativeEditorBlockSurfaces.swift index 3ae3285..e948724 100644 --- a/docmostly/Features/Editor/NativeEditorBlockSurfaces.swift +++ b/docmostly/Features/Editor/NativeEditorBlockSurfaces.swift @@ -215,7 +215,8 @@ struct NativeEditorDiagramBlockView: View { VStack(alignment: .leading, spacing: 10) { if let sourceURL { NativeEditorWebEmbedView( - html: NativeEditorWebEmbedHTML.imageHTML(source: sourceURL, title: displayTitle) + html: NativeEditorWebEmbedHTML.imageHTML(source: sourceURL, title: displayTitle), + allowedHosts: Set([sourceURL.host()?.lowercased()].compactMap(\.self)) ) .frame(minHeight: 260) .clipShape(.rect(cornerRadius: 10)) @@ -279,24 +280,10 @@ struct NativeEditorDiagramBlockView: View { } private var sourceURL: URL? { - guard let source = diagram.source?.trimmingCharacters(in: .whitespacesAndNewlines), - source.isEmpty == false - else { - return nil - } - - if let absoluteURL = URL(string: source), - absoluteURL.scheme?.isEmpty == false { - return absoluteURL - } - - guard let serverURLString, - let serverURL = URL(string: serverURLString) - else { - return nil - } - - return URL(string: source, relativeTo: serverURL)?.absoluteURL + NativeEditorWebURLPolicy.documentResourceURL( + from: diagram.source, + serverURLString: serverURLString + ) } } diff --git a/docmostly/Features/Editor/NativeEditorEmbedBlockView.swift b/docmostly/Features/Editor/NativeEditorEmbedBlockView.swift index 2bd697e..afea4b9 100644 --- a/docmostly/Features/Editor/NativeEditorEmbedBlockView.swift +++ b/docmostly/Features/Editor/NativeEditorEmbedBlockView.swift @@ -11,6 +11,7 @@ struct NativeEditorEmbedBlockView: View { VStack(alignment: .leading, spacing: 10) { if let youtubeSource = embed.youtubePlayerSource { NativeEditorYouTubeEmbedView(source: youtubeSource) + .id(youtubeSource) } else if let spotifyURL = embed.spotifyEmbedURL { NativeEditorSpotifyEmbedView(url: spotifyURL) } else { @@ -66,7 +67,8 @@ private struct NativeEditorSpotifyEmbedView: View { var body: some View { NativeEditorWebEmbedView( - html: NativeEditorWebEmbedHTML.iframeHTML(source: url, title: "Spotify embed") + html: NativeEditorWebEmbedHTML.iframeHTML(source: url, title: "Spotify embed"), + allowedHosts: ["open.spotify.com"] ) .frame(minHeight: 180) .clipShape(.rect(cornerRadius: 10)) @@ -117,8 +119,7 @@ private struct NativeEditorGenericEmbedView: View { } private var sourceURL: URL? { - guard let source = embed.source else { return nil } - return URL(string: source) + NativeEditorWebURLPolicy.webURL(from: embed.source) } } @@ -206,13 +207,15 @@ enum NativeEditorWebEmbedHTML { #if os(iOS) struct NativeEditorWebEmbedView: UIViewRepresentable { let html: String + let allowedHosts: Set func makeCoordinator() -> NativeEditorWebEmbedCoordinator { - NativeEditorWebEmbedCoordinator() + NativeEditorWebEmbedCoordinator(allowedHosts: allowedHosts) } func makeUIView(context: Context) -> WKWebView { let webView = WKWebView() + webView.navigationDelegate = context.coordinator webView.isOpaque = false webView.backgroundColor = .clear webView.scrollView.isScrollEnabled = false @@ -220,35 +223,57 @@ struct NativeEditorWebEmbedView: UIViewRepresentable { } func updateUIView(_ webView: WKWebView, context: Context) { + context.coordinator.allowedHosts = allowedHosts context.coordinator.load(html: html, in: webView) } } #elseif os(macOS) struct NativeEditorWebEmbedView: NSViewRepresentable { let html: String + let allowedHosts: Set func makeCoordinator() -> NativeEditorWebEmbedCoordinator { - NativeEditorWebEmbedCoordinator() + NativeEditorWebEmbedCoordinator(allowedHosts: allowedHosts) } func makeNSView(context: Context) -> WKWebView { let webView = WKWebView() + webView.navigationDelegate = context.coordinator webView.setValue(false, forKey: "drawsBackground") return webView } func updateNSView(_ webView: WKWebView, context: Context) { + context.coordinator.allowedHosts = allowedHosts context.coordinator.load(html: html, in: webView) } } #endif -final class NativeEditorWebEmbedCoordinator { +final class NativeEditorWebEmbedCoordinator: NSObject, WKNavigationDelegate { + var allowedHosts: Set private var loadedHTML: String? + init(allowedHosts: Set) { + self.allowedHosts = allowedHosts + super.init() + } + func load(html: String, in webView: WKWebView) { guard loadedHTML != html else { return } loadedHTML = html webView.loadHTMLString(html, baseURL: nil) } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + let isAllowed = NativeEditorWebURLPolicy.allowsNavigation( + to: navigationAction.request.url, + allowedHosts: allowedHosts + ) + decisionHandler(isAllowed ? .allow : .cancel) + } } diff --git a/docmostly/Features/Editor/NativeEditorEmbedPresentation.swift b/docmostly/Features/Editor/NativeEditorEmbedPresentation.swift index 9ebdba6..f3ccb44 100644 --- a/docmostly/Features/Editor/NativeEditorEmbedPresentation.swift +++ b/docmostly/Features/Editor/NativeEditorEmbedPresentation.swift @@ -25,8 +25,14 @@ nonisolated enum NativeEditorEmbedResolver { } static func youtubeWatchSource(from source: String?) -> String? { - guard let videoID = youtubeVideoID(from: source) else { return nil } - return "https://www.youtube.com/watch?v=\(videoID)" + guard let video = youtubeVideo(from: source) else { return nil } + + var components = URLComponents() + components.scheme = "https" + components.host = "www.youtube.com" + components.path = "/watch" + components.queryItems = [URLQueryItem(name: "v", value: video.id)] + video.timeQueryItems + return components.url?.absoluteString } static func spotifyEmbedURL(from source: String?) -> URL? { @@ -53,7 +59,7 @@ nonisolated enum NativeEditorEmbedResolver { } private static func inferredProvider(from source: String?) -> String? { - if youtubeVideoID(from: source) != nil { + if youtubeVideo(from: source) != nil { return "YouTube" } @@ -64,7 +70,7 @@ nonisolated enum NativeEditorEmbedResolver { return nil } - private static func youtubeVideoID(from source: String?) -> String? { + private static func youtubeVideo(from source: String?) -> YouTubeVideo? { guard let source, let components = URLComponents(string: source), @@ -75,7 +81,9 @@ nonisolated enum NativeEditorEmbedResolver { let normalizedHost = host.hasPrefix("www.") ? String(host.dropFirst(4)) : host if normalizedHost == "youtu.be" { - return nonEmptyVideoID(String(components.path.dropFirst())) + return nonEmptyVideoID(String(components.path.dropFirst())).map { + YouTubeVideo(id: $0, timeQueryItems: youtubeTimeQueryItems(from: components)) + } } guard normalizedHost == "youtube.com" || normalizedHost == "youtube-nocookie.com" else { @@ -83,18 +91,45 @@ nonisolated enum NativeEditorEmbedResolver { } if components.path.hasPrefix("/embed/") { - return nonEmptyVideoID(String(components.path.dropFirst("/embed/".count))) + return nonEmptyVideoID(String(components.path.dropFirst("/embed/".count))).map { + YouTubeVideo(id: $0, timeQueryItems: youtubeTimeQueryItems(from: components)) + } } if components.path == "/watch" { - return components.queryItems?.first { $0.name == "v" }?.value.flatMap(nonEmptyVideoID) + return components.queryItems? + .first { $0.name == "v" }? + .value + .flatMap(nonEmptyVideoID) + .map { + YouTubeVideo(id: $0, timeQueryItems: youtubeTimeQueryItems(from: components)) + } } return nil } + private static func youtubeTimeQueryItems(from components: URLComponents) -> [URLQueryItem] { + let supportedNames: Set = ["start", "t"] + return components.queryItems?.compactMap { item in + guard supportedNames.contains(item.name), + let value = item.value?.trimmingCharacters(in: .whitespacesAndNewlines), + value.isEmpty == false + else { + return nil + } + + return URLQueryItem(name: item.name, value: value) + } ?? [] + } + private static func nonEmptyVideoID(_ value: String) -> String? { let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmedValue.isEmpty ? nil : trimmedValue } + + private struct YouTubeVideo { + let id: String + let timeQueryItems: [URLQueryItem] + } } diff --git a/docmostly/Features/Editor/NativeEditorWebURLPolicy.swift b/docmostly/Features/Editor/NativeEditorWebURLPolicy.swift new file mode 100644 index 0000000..5a33a23 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorWebURLPolicy.swift @@ -0,0 +1,73 @@ +import Foundation + +nonisolated enum NativeEditorWebURLPolicy { + static func webURL(from source: String?) -> URL? { + guard let url = trimmedURL(from: source), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" + else { + return nil + } + + return url + } + + static func documentResourceURL(from source: String?, serverURLString: String?) -> URL? { + guard let source = source?.trimmingCharacters(in: .whitespacesAndNewlines), + source.isEmpty == false, + let serverURLString, + let serverURL = URL(string: serverURLString), + let serverScheme = serverURL.scheme?.lowercased(), + serverScheme == "http" || serverScheme == "https", + serverURL.host() != nil + else { + return nil + } + + let candidateURL: URL? + if let absoluteURL = URL(string: source), absoluteURL.scheme?.isEmpty == false { + candidateURL = absoluteURL + } else { + candidateURL = URL(string: source, relativeTo: serverURL)?.absoluteURL + } + + guard let candidateURL, sameOrigin(candidateURL, as: serverURL) else { + return nil + } + + return candidateURL + } + + static func allowsNavigation(to url: URL?, allowedHosts: Set) -> Bool { + guard let url else { return true } + + let scheme = url.scheme?.lowercased() + if scheme == "about" { + return true + } + + guard scheme == "http" || scheme == "https", + let host = url.host()?.lowercased() + else { + return false + } + + return allowedHosts.contains(host) + } + + private static func trimmedURL(from source: String?) -> URL? { + guard let source = source?.trimmingCharacters(in: .whitespacesAndNewlines), + source.isEmpty == false + else { + return nil + } + + return URL(string: source) + } + + private static func sameOrigin(_ url: URL, as serverURL: URL) -> Bool { + url.scheme?.lowercased() == serverURL.scheme?.lowercased() + && url.host()?.lowercased() == serverURL.host()?.lowercased() + && url.port == serverURL.port + } +} diff --git a/docmostly/Features/PageReader/PageReaderView.swift b/docmostly/Features/PageReader/PageReaderView.swift index ed5dfc6..0a45b18 100644 --- a/docmostly/Features/PageReader/PageReaderView.swift +++ b/docmostly/Features/PageReader/PageReaderView.swift @@ -103,7 +103,6 @@ struct PageReaderView: View { .toolbar { pageReaderToolbar } - #if os(iOS) .safeAreaInset(edge: .bottom) { if let editorViewModel, readerMode == .edit, editorViewModel.isEditing, editorViewModel.canEdit { VStack(spacing: 6) { @@ -131,7 +130,6 @@ struct PageReaderView: View { } } } - #endif .fileImporter( isPresented: $isShowingAttachmentImporter, allowedContentTypes: attachmentAllowedContentTypes, diff --git a/docmostly/Features/Spaces/MainShellView.swift b/docmostly/Features/Spaces/MainShellView.swift index b3b4281..95928a0 100644 --- a/docmostly/Features/Spaces/MainShellView.swift +++ b/docmostly/Features/Spaces/MainShellView.swift @@ -31,7 +31,6 @@ struct MainShellView: View { } private func loadSpacesIfNeeded() async { - guard appState.spaces.isEmpty else { return } await appState.loadSpaces() } } diff --git a/docmostlyTests/Editor/NativeEditorEmbedPresentationTests.swift b/docmostlyTests/Editor/NativeEditorEmbedPresentationTests.swift index 6198775..95e90be 100644 --- a/docmostlyTests/Editor/NativeEditorEmbedPresentationTests.swift +++ b/docmostlyTests/Editor/NativeEditorEmbedPresentationTests.swift @@ -85,6 +85,61 @@ struct NativeEditorEmbedPresentationTests { #expect(embed.youtubePlayerSource == "https://www.youtube.com/watch?v=DTUDPMmy6IU") } + @Test func preservesYouTubeStartParametersInPlayerSource() { + #expect( + NativeEditorEmbedResolver.youtubeWatchSource( + from: "https://www.youtube.com/watch?v=DTUDPMmy6IU&start=90" + ) == "https://www.youtube.com/watch?v=DTUDPMmy6IU&start=90" + ) + #expect( + NativeEditorEmbedResolver.youtubeWatchSource( + from: "https://youtu.be/DTUDPMmy6IU?t=1m30s" + ) == "https://www.youtube.com/watch?v=DTUDPMmy6IU&t=1m30s" + ) + } + + @Test func genericWebEmbedLinksRejectNonWebSchemes() { + #expect(NativeEditorWebURLPolicy.webURL(from: "https://docs.example.com/embed") != nil) + #expect(NativeEditorWebURLPolicy.webURL(from: "http://docs.example.com/embed") != nil) + #expect(NativeEditorWebURLPolicy.webURL(from: "file:///etc/passwd") == nil) + #expect(NativeEditorWebURLPolicy.webURL(from: "docmostly://page/1") == nil) + } + + @Test func documentResourcesStayOnServerOrigin() { + let serverURLString = "https://docs.example.com" + + #expect( + NativeEditorWebURLPolicy.documentResourceURL( + from: "/uploads/diagram.svg", + serverURLString: serverURLString + )?.absoluteString == "https://docs.example.com/uploads/diagram.svg" + ) + #expect( + NativeEditorWebURLPolicy.documentResourceURL( + from: "https://docs.example.com/uploads/diagram.svg", + serverURLString: serverURLString + )?.absoluteString == "https://docs.example.com/uploads/diagram.svg" + ) + #expect( + NativeEditorWebURLPolicy.documentResourceURL( + from: "https://evil.example.com/uploads/diagram.svg", + serverURLString: serverURLString + ) == nil + ) + #expect( + NativeEditorWebURLPolicy.documentResourceURL( + from: "file:///etc/passwd", + serverURLString: serverURLString + ) == nil + ) + #expect( + NativeEditorWebURLPolicy.documentResourceURL( + from: "//evil.example.com/uploads/diagram.svg", + serverURLString: serverURLString + ) == nil + ) + } + private func columnNode(_ text: String) -> ProseMirrorNode { ProseMirrorNode( type: "column", From 80cb3ba4570666529fefa9c4693bc20771cbad46 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Wed, 8 Jul 2026 02:01:42 +0100 Subject: [PATCH 3/6] Refactor page navigation and space settings UI Consolidates page navigation patterns by introducing PageOpenTarget, a unified type for page navigation across the app. Removes redundant destination views (NotificationDestinationView, PageBrowserDestinationView, SearchResultDestinationView) and replaces NavigationLink usage with PageOpenLink for consistent platform-specific behavior. Refactors SpaceSettingsDetailFormView to use a tab-based UI with Settings, Members, and Security tabs, and introduces SpaceSettingsDialog for macOS modal presentation. Updates MacDesktopCommandController and command handlers to request space settings presentation instead of direct sidebar selection. Includes tests for PageOpenTarget initialization and navigation behavior with space switching. --- DocmostlyMac/DocmostlyMacCommands.swift | 12 +- .../MacDesktopCommandController.swift | 10 + DocmostlyMac/MacPageWindowView.swift | 8 +- DocmostlyMac/MacRootView.swift | 7 +- DocmostlyMacUITests/DocmostlyMacUITests.swift | 21 ++ docmostly/App/AppState+Navigation.swift | 8 + .../Editor/NativeEditorSubpagesView.swift | 9 +- .../Favorites/FavoriteDestinationView.swift | 12 +- .../Features/Favorites/FavoritesView.swift | 42 ++- .../NotificationDestinationView.swift | 21 -- .../Notifications/NotificationListView.swift | 12 +- .../Features/PageReader/PageOpenTarget.swift | 139 ++++++++ .../Features/PageReader/RecentPagesView.swift | 9 +- .../PageTree/PageBrowserDestinationView.swift | 14 - .../PageTree/PageBrowserHomeView.swift | 6 +- .../Features/PageTree/PageTreeView.swift | 7 +- .../PageTree/RecentPagesRailView.swift | 2 +- .../Search/SearchResultDestinationView.swift | 14 - .../Features/Search/SearchResultRowView.swift | 2 +- docmostly/Features/Search/SearchView.swift | 4 +- .../SpaceSettingsDetailFormView.swift | 328 ++++++++++++++---- .../Settings/SpaceSettingsDetailView.swift | 10 +- .../Settings/SpaceSettingsDialog.swift | 29 ++ .../Spaces/MacMainShellDetailView.swift | 45 ++- .../Spaces/MacWorkspaceSidebarView.swift | 7 +- docmostly/Features/Spaces/MainShellView.swift | 35 ++ .../AppStateNavigationSelectionTests.swift | 18 + .../PageReader/PageOpenTargetTests.swift | 174 ++++++++++ 28 files changed, 805 insertions(+), 200 deletions(-) delete mode 100644 docmostly/Features/Notifications/NotificationDestinationView.swift create mode 100644 docmostly/Features/PageReader/PageOpenTarget.swift delete mode 100644 docmostly/Features/PageTree/PageBrowserDestinationView.swift delete mode 100644 docmostly/Features/Search/SearchResultDestinationView.swift create mode 100644 docmostly/Features/Settings/SpaceSettingsDialog.swift create mode 100644 docmostlyTests/PageReader/PageOpenTargetTests.swift diff --git a/DocmostlyMac/DocmostlyMacCommands.swift b/DocmostlyMac/DocmostlyMacCommands.swift index ba27e93..c577e10 100644 --- a/DocmostlyMac/DocmostlyMacCommands.swift +++ b/DocmostlyMac/DocmostlyMacCommands.swift @@ -55,7 +55,7 @@ struct DocmostlyMacCommands: Commands { .disabled(appState.phase != .authenticated) Button("Space Settings", systemImage: "gearshape") { - select(.settings) + presentSpaceSettings() } .keyboardShortcut("4", modifiers: .command) .disabled(appState.phase != .authenticated) @@ -117,6 +117,16 @@ struct DocmostlyMacCommands: Commands { openWindow(id: "main") } + private func presentSpaceSettings() { + if let focusedActions { + focusedActions.presentSpaceSettings() + return + } + + commandController.requestSpaceSettingsPresentation() + openWindow(id: "main") + } + private func showSettings() { MacSettingsWindowController.show( appState: appState, diff --git a/DocmostlyMac/MacDesktopCommandController.swift b/DocmostlyMac/MacDesktopCommandController.swift index a8d5b5d..f4311d4 100644 --- a/DocmostlyMac/MacDesktopCommandController.swift +++ b/DocmostlyMac/MacDesktopCommandController.swift @@ -6,10 +6,19 @@ import SwiftUI @Observable final class MacDesktopCommandController { var sidebarReloadRequestID = UUID() + var spaceSettingsPresentationRequestID: UUID? func requestSidebarReload() { sidebarReloadRequestID = UUID() } + + func requestSpaceSettingsPresentation() { + spaceSettingsPresentationRequestID = UUID() + } + + func clearSpaceSettingsPresentationRequest() { + spaceSettingsPresentationRequestID = nil + } } struct MacDesktopCommandActions { @@ -17,6 +26,7 @@ struct MacDesktopCommandActions { let selectedPageRoute: () -> MacPageWindowRoute? let presentCommandPalette: () -> Void let presentPageCreation: () -> Void + let presentSpaceSettings: () -> Void let selectSidebarDestination: (SidebarDestination) -> Void let openSelectedPageInNewWindow: () -> Void } diff --git a/DocmostlyMac/MacPageWindowView.swift b/DocmostlyMac/MacPageWindowView.swift index aeb2b06..9418917 100644 --- a/DocmostlyMac/MacPageWindowView.swift +++ b/DocmostlyMac/MacPageWindowView.swift @@ -115,7 +115,7 @@ struct MacPageWindowView: View { keywords: ["permissions", "members"], isEnabled: appState.phase == .authenticated ) { - selectSidebarDestination(.settings) + presentSpaceSettings() }, MacCommandPaletteItem( title: "Refresh Spaces", @@ -156,6 +156,7 @@ struct MacPageWindowView: View { selectedPageRoute: { selectedPageRoute }, presentCommandPalette: presentCommandPalette, presentPageCreation: presentPageCreation, + presentSpaceSettings: presentSpaceSettings, selectSidebarDestination: selectSidebarDestination, openSelectedPageInNewWindow: openSelectedPageInNewWindow ) @@ -179,6 +180,11 @@ struct MacPageWindowView: View { isPageCreationPresented = true } + private func presentSpaceSettings() { + commandController.requestSpaceSettingsPresentation() + openWindow(id: "main") + } + private func updateLoadedPageContext(pageID: String, spaceID: String, title: String) { loadedPageID = pageID loadedPageSpaceID = spaceID diff --git a/DocmostlyMac/MacRootView.swift b/DocmostlyMac/MacRootView.swift index 3b51ca1..d0d3a05 100644 --- a/DocmostlyMac/MacRootView.swift +++ b/DocmostlyMac/MacRootView.swift @@ -113,7 +113,7 @@ struct MacRootView: View { keywords: ["permissions", "members"], isEnabled: appState.phase == .authenticated ) { - appState.selectSidebarUtilityDestination(.settings) + presentSpaceSettings() }, MacCommandPaletteItem( title: "Refresh Spaces", @@ -162,6 +162,7 @@ struct MacRootView: View { selectedPageRoute: { selectedPageRoute }, presentCommandPalette: presentCommandPalette, presentPageCreation: presentPageCreation, + presentSpaceSettings: presentSpaceSettings, selectSidebarDestination: selectSidebarDestination, openSelectedPageInNewWindow: openSelectedPageInNewWindow ) @@ -175,6 +176,10 @@ struct MacRootView: View { isPageCreationPresented = true } + private func presentSpaceSettings() { + commandController.requestSpaceSettingsPresentation() + } + private func createRootPage(title: String) async -> String? { guard let selectedSpace else { return "No space selected." diff --git a/DocmostlyMacUITests/DocmostlyMacUITests.swift b/DocmostlyMacUITests/DocmostlyMacUITests.swift index deab3bb..5c33c5f 100644 --- a/DocmostlyMacUITests/DocmostlyMacUITests.swift +++ b/DocmostlyMacUITests/DocmostlyMacUITests.swift @@ -47,6 +47,27 @@ final class DocmostlyMacUITests: XCTestCase { XCTAssertTrue(app.staticTexts["Roadmap native preview content"].waitForExistence(timeout: 5)) } + func testPreviewShellCanSwitchSpacesAfterOpeningOverviewPage() throws { + let app = launchMainShellPreview() + + XCTAssertTrue(app.buttons["PageOpenLink.roadmap"].waitForExistence(timeout: 5)) + app.buttons["PageOpenLink.roadmap"].click() + XCTAssertTrue(app.textFields["Page title"].waitForExistence(timeout: 5)) + XCTAssertEqual(app.textFields["Page title"].value as? String, "Roadmap") + + app.buttons["Product"].click() + XCTAssertTrue(app.menuItems["Engineering"].waitForExistence(timeout: 5)) + app.menuItems["Engineering"].click() + + XCTAssertTrue(app.buttons["PageOpenLink.architecture"].waitForExistence(timeout: 5)) + XCTAssertFalse(app.images["Warning"].exists) + + app.buttons["PageOpenLink.architecture"].click() + XCTAssertTrue(app.textFields["Page title"].waitForExistence(timeout: 5)) + XCTAssertEqual(app.textFields["Page title"].value as? String, "Architecture") + XCTAssertTrue(app.staticTexts["Architecture native preview content"].waitForExistence(timeout: 5)) + } + func testLaunchPerformance() throws { measure(metrics: [XCTApplicationLaunchMetric()]) { XCUIApplication().launch() diff --git a/docmostly/App/AppState+Navigation.swift b/docmostly/App/AppState+Navigation.swift index bebb3b9..d57b755 100644 --- a/docmostly/App/AppState+Navigation.swift +++ b/docmostly/App/AppState+Navigation.swift @@ -40,6 +40,14 @@ extension AppState { selectedPageID = pageID } + func openPage(_ target: PageOpenTarget) { + selectPage( + id: target.slugId, + spaceID: target.spaceId, + revealSpaceInSidebar: target.revealSpaceInSidebar + ) + } + func clearSelectedPage() { selectedPageID = nil } diff --git a/docmostly/Features/Editor/NativeEditorSubpagesView.swift b/docmostly/Features/Editor/NativeEditorSubpagesView.swift index 0b573bb..77a9660 100644 --- a/docmostly/Features/Editor/NativeEditorSubpagesView.swift +++ b/docmostly/Features/Editor/NativeEditorSubpagesView.swift @@ -16,7 +16,7 @@ struct NativeEditorSubpagesView: View { } ForEach(pages) { page in - NavigationLink(value: page) { + PageOpenLink(target: PageOpenTarget(page: page, revealSpaceInSidebar: true)) { PageListRowView(page: page, systemImage: page.hasChildren == true ? "doc.on.doc" : "doc.text") } } @@ -36,12 +36,7 @@ struct NativeEditorSubpagesView: View { .task(id: taskID) { await load() } - .navigationDestination(for: DocmostPage.self) { page in - PageReaderView(pageID: page.slugId) - .task(id: page.id) { - appState.selectPage(id: page.slugId, spaceID: page.spaceId) - } - } + .pageOpenDestination() } private var taskID: String { diff --git a/docmostly/Features/Favorites/FavoriteDestinationView.swift b/docmostly/Features/Favorites/FavoriteDestinationView.swift index 47189d2..2cda7f6 100644 --- a/docmostly/Features/Favorites/FavoriteDestinationView.swift +++ b/docmostly/Features/Favorites/FavoriteDestinationView.swift @@ -1,21 +1,13 @@ import SwiftUI struct FavoriteDestinationView: View { - @Environment(AppState.self) private var appState - let favorite: DocmostFavorite var body: some View { switch favorite.type { case .page: - if let targetID = favorite.targetID { - PageReaderView(pageID: targetID) - .task(id: favorite.id) { - appState.selectPage( - id: targetID, - spaceID: favorite.page?.spaceId ?? favorite.spaceId - ) - } + if let target = PageOpenTarget(favorite: favorite) { + PageOpenDestinationView(target: target) } else { ContentUnavailableView("Page unavailable", systemImage: "doc.text") } diff --git a/docmostly/Features/Favorites/FavoritesView.swift b/docmostly/Features/Favorites/FavoritesView.swift index f798d20..729c428 100644 --- a/docmostly/Features/Favorites/FavoritesView.swift +++ b/docmostly/Features/Favorites/FavoritesView.swift @@ -12,13 +12,7 @@ struct FavoritesView: View { Section("Favorites") { ForEach(viewModel.favorites) { favorite in - if favorite.targetID == nil { - FavoriteRowView(favorite: favorite) - } else { - NavigationLink(value: favorite) { - FavoriteRowView(favorite: favorite) - } - } + favoriteRow(favorite) } } @@ -53,5 +47,39 @@ struct FavoritesView: View { .navigationDestination(for: DocmostFavorite.self) { favorite in FavoriteDestinationView(favorite: favorite) } + .pageOpenDestination() + } + + @ViewBuilder + private func favoriteRow(_ favorite: DocmostFavorite) -> some View { + switch favorite.type { + case .page: + if let target = PageOpenTarget(favorite: favorite) { + PageOpenLink(target: target) { + FavoriteRowView(favorite: favorite) + } + } else { + FavoriteRowView(favorite: favorite) + } + case .space: + if let targetID = favorite.targetID { + #if os(macOS) + Button { + appState.selectSpace(id: targetID) + } label: { + FavoriteRowView(favorite: favorite) + } + .buttonStyle(.plain) + #else + NavigationLink(value: favorite) { + FavoriteRowView(favorite: favorite) + } + #endif + } else { + FavoriteRowView(favorite: favorite) + } + case .template: + FavoriteRowView(favorite: favorite) + } } } diff --git a/docmostly/Features/Notifications/NotificationDestinationView.swift b/docmostly/Features/Notifications/NotificationDestinationView.swift deleted file mode 100644 index 4cce4f6..0000000 --- a/docmostly/Features/Notifications/NotificationDestinationView.swift +++ /dev/null @@ -1,21 +0,0 @@ -import SwiftUI - -struct NotificationDestinationView: View { - @Environment(AppState.self) private var appState - - let notification: DocmostNotification - - var body: some View { - if let page = notification.page { - PageReaderView(pageID: page.slugId) - .task(id: notification.id) { - appState.selectPage( - id: page.slugId, - spaceID: notification.spaceId ?? notification.space?.id - ) - } - } else { - ContentUnavailableView("Page unavailable", systemImage: "bell") - } - } -} diff --git a/docmostly/Features/Notifications/NotificationListView.swift b/docmostly/Features/Notifications/NotificationListView.swift index cc44091..df5231a 100644 --- a/docmostly/Features/Notifications/NotificationListView.swift +++ b/docmostly/Features/Notifications/NotificationListView.swift @@ -22,12 +22,12 @@ struct NotificationListView: View { Section(header: NotificationSectionHeaderView(unreadCount: viewModel.unreadCount)) { ForEach(viewModel.notifications) { notification in Group { - if notification.page == nil { - NotificationRowView(notification: notification) - } else { - NavigationLink(value: notification) { + if let target = PageOpenTarget(notification: notification) { + PageOpenLink(target: target) { NotificationRowView(notification: notification) } + } else { + NotificationRowView(notification: notification) } } .swipeActions(edge: .trailing, allowsFullSwipe: true) { @@ -78,8 +78,6 @@ struct NotificationListView: View { .refreshable { await viewModel.load(appState: appState) } - .navigationDestination(for: DocmostNotification.self) { notification in - NotificationDestinationView(notification: notification) - } + .pageOpenDestination() } } diff --git a/docmostly/Features/PageReader/PageOpenTarget.swift b/docmostly/Features/PageReader/PageOpenTarget.swift new file mode 100644 index 0000000..5b30694 --- /dev/null +++ b/docmostly/Features/PageReader/PageOpenTarget.swift @@ -0,0 +1,139 @@ +import SwiftUI + +nonisolated struct PageOpenTarget: Identifiable, Hashable, Sendable { + let id: String + let slugId: String + let spaceId: String? + let revealSpaceInSidebar: Bool + + init( + id: String, + slugId: String, + spaceId: String?, + revealSpaceInSidebar: Bool = false + ) { + self.id = id + self.slugId = slugId + self.spaceId = spaceId + self.revealSpaceInSidebar = revealSpaceInSidebar + } + + init(page: DocmostPage, revealSpaceInSidebar: Bool = false) { + self.init( + id: page.id, + slugId: page.slugId, + spaceId: page.spaceId, + revealSpaceInSidebar: revealSpaceInSidebar + ) + } + + init(item: PageBrowserItem, revealSpaceInSidebar: Bool = false) { + self.init( + id: item.id, + slugId: item.slugId, + spaceId: item.spaceId, + revealSpaceInSidebar: revealSpaceInSidebar + ) + } + + init(searchResult: DocmostSearchResult, revealSpaceInSidebar: Bool = false) { + self.init( + id: searchResult.id, + slugId: searchResult.slugId, + spaceId: searchResult.space.id, + revealSpaceInSidebar: revealSpaceInSidebar + ) + } + + init?(favorite: DocmostFavorite, revealSpaceInSidebar: Bool = false) { + guard favorite.type == .page else { return nil } + + if let page = favorite.page { + self.init( + id: page.id, + slugId: page.slugId, + spaceId: page.spaceId, + revealSpaceInSidebar: revealSpaceInSidebar + ) + return + } + + guard let pageID = favorite.pageId else { return nil } + self.init( + id: pageID, + slugId: pageID, + spaceId: favorite.spaceId, + revealSpaceInSidebar: revealSpaceInSidebar + ) + } + + init?(notification: DocmostNotification, revealSpaceInSidebar: Bool = false) { + guard let page = notification.page else { return nil } + + self.init( + id: page.id, + slugId: page.slugId, + spaceId: notification.spaceId ?? notification.space?.id, + revealSpaceInSidebar: revealSpaceInSidebar + ) + } +} + +struct PageOpenLink: View { + @Environment(AppState.self) private var appState + + let target: PageOpenTarget + let label: () -> Label + + init(target: PageOpenTarget, @ViewBuilder label: @escaping () -> Label) { + self.target = target + self.label = label + } + + var body: some View { + #if os(macOS) + Button { + appState.openPage(target) + } label: { + label() + } + .buttonStyle(.plain) + .accessibilityIdentifier(accessibilityIdentifier) + #else + NavigationLink(value: target) { + label() + } + .accessibilityIdentifier(accessibilityIdentifier) + #endif + } + + private var accessibilityIdentifier: String { + "PageOpenLink.\(target.slugId)" + } +} + +struct PageOpenDestinationView: View { + @Environment(AppState.self) private var appState + + let target: PageOpenTarget + + var body: some View { + PageReaderView(pageID: target.slugId) + .task(id: target.id) { + appState.openPage(target) + } + } +} + +extension View { + @ViewBuilder + func pageOpenDestination() -> some View { + #if os(iOS) + navigationDestination(for: PageOpenTarget.self) { target in + PageOpenDestinationView(target: target) + } + #else + self + #endif + } +} diff --git a/docmostly/Features/PageReader/RecentPagesView.swift b/docmostly/Features/PageReader/RecentPagesView.swift index 601dca2..66e0932 100644 --- a/docmostly/Features/PageReader/RecentPagesView.swift +++ b/docmostly/Features/PageReader/RecentPagesView.swift @@ -12,7 +12,7 @@ struct RecentPagesView: View { Section(appState.isOffline ? "Recent cached pages" : "Recent pages") { ForEach(viewModel.pages) { page in - NavigationLink(value: page) { + PageOpenLink(target: PageOpenTarget(page: page)) { PageListRowView(page: page, systemImage: "clock") } } @@ -46,11 +46,6 @@ struct RecentPagesView: View { .refreshable { await viewModel.load(appState: appState) } - .navigationDestination(for: DocmostPage.self) { page in - PageReaderView(pageID: page.slugId) - .task(id: page.id) { - appState.selectPage(id: page.slugId, spaceID: page.spaceId) - } - } + .pageOpenDestination() } } diff --git a/docmostly/Features/PageTree/PageBrowserDestinationView.swift b/docmostly/Features/PageTree/PageBrowserDestinationView.swift deleted file mode 100644 index a40f94a..0000000 --- a/docmostly/Features/PageTree/PageBrowserDestinationView.swift +++ /dev/null @@ -1,14 +0,0 @@ -import SwiftUI - -struct PageBrowserDestinationView: View { - @Environment(AppState.self) private var appState - - let item: PageBrowserItem - - var body: some View { - PageReaderView(pageID: item.slugId) - .task(id: item.id) { - appState.selectPage(id: item.slugId, spaceID: item.spaceId) - } - } -} diff --git a/docmostly/Features/PageTree/PageBrowserHomeView.swift b/docmostly/Features/PageTree/PageBrowserHomeView.swift index e137f49..e909046 100644 --- a/docmostly/Features/PageTree/PageBrowserHomeView.swift +++ b/docmostly/Features/PageTree/PageBrowserHomeView.swift @@ -24,7 +24,7 @@ struct PageBrowserHomeView: View { Section(viewModel.selectedScope.title) { ForEach(viewModel.items) { item in - NavigationLink(value: item) { + PageOpenLink(target: PageOpenTarget(item: item)) { PageBrowserRowView(item: item) } .listRowInsets(PageBrowserMetrics.rowInsets) @@ -51,9 +51,7 @@ struct PageBrowserHomeView: View { .refreshable { await viewModel.load(space: space, provider: appState) } - .navigationDestination(for: PageBrowserItem.self) { item in - PageBrowserDestinationView(item: item) - } + .pageOpenDestination() } private var pageBrowserTaskKey: PageBrowserTaskKey { diff --git a/docmostly/Features/PageTree/PageTreeView.swift b/docmostly/Features/PageTree/PageTreeView.swift index ee345d4..f12af55 100644 --- a/docmostly/Features/PageTree/PageTreeView.swift +++ b/docmostly/Features/PageTree/PageTreeView.swift @@ -126,15 +126,10 @@ struct PageTreeView: View { return } } - .navigationDestination(for: PageBrowserItem.self) { item in - PageBrowserDestinationView(item: item) - } + .pageOpenDestination() .navigationDestination(for: PageTreeNode.self) { node in PageReaderDestinationView(pageID: node.slugId) } - .navigationDestination(for: DocmostSearchResult.self) { result in - SearchResultDestinationView(result: result) - } .sheet(item: $creationRequest) { request in PageCreationSheet(request: request) { title in await createPage(title: title, parentPageId: request.parentPageId) diff --git a/docmostly/Features/PageTree/RecentPagesRailView.swift b/docmostly/Features/PageTree/RecentPagesRailView.swift index 3b8293b..bfa3225 100644 --- a/docmostly/Features/PageTree/RecentPagesRailView.swift +++ b/docmostly/Features/PageTree/RecentPagesRailView.swift @@ -26,7 +26,7 @@ struct RecentPagesRailView: View { ScrollView(.horizontal) { LazyHStack(spacing: PageBrowserMetrics.railCardSpacing) { ForEach(items) { item in - NavigationLink(value: item) { + PageOpenLink(target: PageOpenTarget(item: item)) { RecentPageCardView(item: item) } .buttonStyle(.plain) diff --git a/docmostly/Features/Search/SearchResultDestinationView.swift b/docmostly/Features/Search/SearchResultDestinationView.swift deleted file mode 100644 index 6e71d66..0000000 --- a/docmostly/Features/Search/SearchResultDestinationView.swift +++ /dev/null @@ -1,14 +0,0 @@ -import SwiftUI - -struct SearchResultDestinationView: View { - @Environment(AppState.self) private var appState - - let result: DocmostSearchResult - - var body: some View { - PageReaderView(pageID: result.slugId) - .task(id: result.id) { - appState.selectPage(id: result.slugId, spaceID: result.space.id) - } - } -} diff --git a/docmostly/Features/Search/SearchResultRowView.swift b/docmostly/Features/Search/SearchResultRowView.swift index af7b455..13de61f 100644 --- a/docmostly/Features/Search/SearchResultRowView.swift +++ b/docmostly/Features/Search/SearchResultRowView.swift @@ -4,7 +4,7 @@ struct SearchResultRowView: View { let result: DocmostSearchResult var body: some View { - NavigationLink(value: result) { + PageOpenLink(target: PageOpenTarget(searchResult: result)) { VStack(alignment: .leading, spacing: 6) { Label { Text(result.title.isEmpty ? "Untitled" : result.title) diff --git a/docmostly/Features/Search/SearchView.swift b/docmostly/Features/Search/SearchView.swift index 494cfab..e031cc4 100644 --- a/docmostly/Features/Search/SearchView.swift +++ b/docmostly/Features/Search/SearchView.swift @@ -23,9 +23,7 @@ struct SearchView: View { return } } - .navigationDestination(for: DocmostSearchResult.self) { result in - SearchResultDestinationView(result: result) - } + .pageOpenDestination() } } diff --git a/docmostly/Features/Settings/SpaceSettingsDetailFormView.swift b/docmostly/Features/Settings/SpaceSettingsDetailFormView.swift index ea83426..d81d0f2 100644 --- a/docmostly/Features/Settings/SpaceSettingsDetailFormView.swift +++ b/docmostly/Features/Settings/SpaceSettingsDetailFormView.swift @@ -5,99 +5,193 @@ struct SpaceSettingsDetailFormView: View { @Environment(\.dismiss) private var dismiss @Bindable var viewModel: SpaceSettingsViewModel let canManage: Bool + let showsCloseButton: Bool + @State private var selectedTab = SpaceSettingsTab.settings @State private var memberSearchText = "" @State private var isConfirmingDelete = false @State private var isShowingAddMembers = false var body: some View { - Form { - Section("Details") { - TextField("Name", text: $viewModel.draft.name) - .docmostlyTextInputAutocapitalization(.words) - .onChange(of: viewModel.draft.name) { _, newValue in - viewModel.draft.setName(newValue) - } - TextField("Slug", text: $viewModel.draft.slug) - .docmostlyTextInputAutocapitalization(.never) - TextField("Description", text: $viewModel.draft.description, axis: .vertical) - .lineLimit(2...) + VStack(spacing: 0) { + SpaceSettingsTabBar(selection: $selectedTab) + + Divider() + + selectedTabContent + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .navigationTitle(viewModel.space.name) + .toolbar { + if showsCloseButton { + ToolbarItem(placement: .cancellationAction) { + Button("Close", systemImage: "xmark", action: close) + } } - .disabled(canManage == false) - Section("Security") { - Toggle("Disable public sharing", isOn: $viewModel.draft.disablePublicSharing) - Toggle("Allow viewer comments", isOn: $viewModel.draft.allowViewerComments) + ToolbarItem(placement: .confirmationAction) { + Button("Save", systemImage: "checkmark", action: save) + .disabled(viewModel.canSave == false || viewModel.isSaving || canManage == false) } - .disabled(canManage == false) + } + .task(id: viewModel.space.id) { + await viewModel.loadMembers(appState: appState) + await viewModel.loadWatchStatus(appState: appState) + } + .confirmationDialog("Delete Space", isPresented: $isConfirmingDelete) { + Button("Delete", role: .destructive, action: deleteSpace) + Button("Cancel", role: .cancel) { } + } + .sheet(isPresented: $isShowingAddMembers) { + SpaceAddMembersSheet(viewModel: viewModel) + } + } - Section("Notifications") { - Toggle("Watch this space", isOn: watchingSpaceBinding) - .disabled(viewModel.isLoadingWatchStatus || viewModel.isTogglingWatch) + @ViewBuilder + private var selectedTabContent: some View { + switch selectedTab { + case .settings: + settingsTab + case .members: + membersTab + case .security: + securityTab + } + } - if viewModel.isLoadingWatchStatus || viewModel.isTogglingWatch { - ProgressView(viewModel.isLoadingWatchStatus ? "Loading watch status" : "Updating watch status") - } - } + private var settingsTab: some View { + SpaceSettingsPanelScrollView { + VStack(alignment: .leading, spacing: SpaceSettingsDialogMetrics.sectionSpacing) { + VStack(alignment: .leading, spacing: SpaceSettingsDialogMetrics.rowSpacing) { + Text("Details") + .font(.headline) - Section("Members") { - if canManage { - Button("Add Members", systemImage: "person.badge.plus", action: showAddMembers) - } + SpaceSettingsLabeledRow(title: "Icon") { + SpaceIconView(space: viewModel.space, size: 44) + } - TextField("Search members", text: $memberSearchText) - .docmostlyTextInputAutocapitalization(.never) + SpaceSettingsLabeledRow(title: "Name") { + TextField("Name", text: $viewModel.draft.name) + .docmostlyTextInputAutocapitalization(.words) + .onChange(of: viewModel.draft.name) { _, newValue in + viewModel.draft.setName(newValue) + } + } - if viewModel.isLoading { - ProgressView("Loading members") + SpaceSettingsLabeledRow(title: "Slug") { + TextField("Slug", text: $viewModel.draft.slug) + .docmostlyTextInputAutocapitalization(.never) + } + + SpaceSettingsLabeledRow(title: "Description") { + TextField("Description", text: $viewModel.draft.description, axis: .vertical) + .lineLimit(2...) + } } + .disabled(canManage == false) + + Divider() + + VStack(alignment: .leading, spacing: SpaceSettingsDialogMetrics.rowSpacing) { + Text("Notifications") + .font(.headline) - ForEach(viewModel.filteredMembers(query: memberSearchText)) { member in - SpaceMemberRowView( - member: member, - canManage: canManage, - changeRole: changeRole, - remove: removeMember - ) + Toggle("Watch this space", isOn: watchingSpaceBinding) + .disabled(viewModel.isLoadingWatchStatus || viewModel.isTogglingWatch) + + if viewModel.isLoadingWatchStatus || viewModel.isTogglingWatch { + ProgressView(viewModel.isLoadingWatchStatus ? "Loading watch status" : "Updating watch status") + } } - if viewModel.members.isEmpty, viewModel.isLoading == false { - ContentUnavailableView("No Members", systemImage: "person.2") + statusMessageView + + if canManage { + Divider() + + Button("Delete Space", systemImage: "trash", role: .destructive, action: confirmDelete) } } + } + } + + private var membersTab: some View { + SpaceSettingsPanelScrollView(maxHeight: 420) { + VStack(alignment: .leading, spacing: SpaceSettingsDialogMetrics.rowSpacing) { + HStack { + TextField("Search members", text: $memberSearchText) + .docmostlyTextInputAutocapitalization(.never) - if let message = viewModel.errorMessage { - Section { - SettingsStatusMessageView(message: message, isError: true) + Spacer(minLength: 12) + + Button("Add Members", systemImage: "person.badge.plus", action: showAddMembers) + .disabled(canManage == false) } - } else if let message = viewModel.statusMessage { - Section { - SettingsStatusMessageView(message: message, isError: false) + + if viewModel.isLoading { + ProgressView("Loading members") } - } - if canManage { - Section { - Button("Delete Space", systemImage: "trash", role: .destructive, action: confirmDelete) + VStack(spacing: 0) { + ForEach(viewModel.filteredMembers(query: memberSearchText)) { member in + SpaceMemberRowView( + member: member, + canManage: canManage, + changeRole: changeRole, + remove: removeMember + ) + .padding(.vertical, 8) + + Divider() + } + + if viewModel.members.isEmpty, viewModel.isLoading == false { + Label("No Members", systemImage: "person.2") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + } } + + statusMessageView } } - .navigationTitle(viewModel.space.name) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Save", systemImage: "checkmark", action: save) - .disabled(viewModel.canSave == false || viewModel.isSaving || canManage == false) + } + + private var securityTab: some View { + SpaceSettingsPanelScrollView { + VStack(alignment: .leading, spacing: SpaceSettingsDialogMetrics.sectionSpacing) { + VStack(alignment: .leading, spacing: SpaceSettingsDialogMetrics.sectionSpacing) { + Toggle(isOn: $viewModel.draft.disablePublicSharing) { + VStack(alignment: .leading) { + Text("Disable public sharing") + Text("Prevent pages in this space from being shared publicly.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + Toggle(isOn: $viewModel.draft.allowViewerComments) { + VStack(alignment: .leading) { + Text("Allow viewers to comment") + Text("Allow viewers to add comments on pages in this space.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + .disabled(canManage == false) + + statusMessageView } } - .task(id: viewModel.space.id) { - await viewModel.loadMembers(appState: appState) - await viewModel.loadWatchStatus(appState: appState) - } - .confirmationDialog("Delete Space", isPresented: $isConfirmingDelete) { - Button("Delete", role: .destructive, action: deleteSpace) - Button("Cancel", role: .cancel) { } - } - .sheet(isPresented: $isShowingAddMembers) { - SpaceAddMembersSheet(viewModel: viewModel) + } + + @ViewBuilder + private var statusMessageView: some View { + if let message = viewModel.errorMessage { + SettingsStatusMessageView(message: message, isError: true) + } else if let message = viewModel.statusMessage { + SettingsStatusMessageView(message: message, isError: false) } } @@ -107,6 +201,10 @@ struct SpaceSettingsDetailFormView: View { } } + private func close() { + dismiss() + } + private var watchingSpaceBinding: Binding { Binding { viewModel.isWatchingSpace @@ -146,3 +244,103 @@ struct SpaceSettingsDetailFormView: View { } } } + +private enum SpaceSettingsTab: CaseIterable, Identifiable { + case settings + case members + case security + + var id: Self { self } + + var title: String { + switch self { + case .settings: + "Settings" + case .members: + "Members" + case .security: + "Security" + } + } +} + +private struct SpaceSettingsTabBar: View { + @Binding var selection: SpaceSettingsTab + + var body: some View { + HStack(spacing: 0) { + ForEach(SpaceSettingsTab.allCases) { tab in + Button { + selection = tab + } label: { + Text(tab.title) + .font(selection == tab ? .headline : .body) + .frame(minWidth: 96) + .padding(.vertical, 11) + .contentShape(.rect) + } + .buttonStyle(.plain) + .foregroundStyle(selection == tab ? .primary : .secondary) + .overlay(alignment: .bottom) { + if selection == tab { + Rectangle() + .fill(.primary) + .frame(height: 2) + } + } + .accessibilityValue(selection == tab ? "Selected" : "") + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 32) + .padding(.top, 22) + } +} + +private struct SpaceSettingsPanelScrollView: View { + let maxHeight: CGFloat? + @ViewBuilder let content: Content + + init(maxHeight: CGFloat? = nil, @ViewBuilder content: () -> Content) { + self.maxHeight = maxHeight + self.content = content() + } + + var body: some View { + ScrollView { + content + .padding(.horizontal, 32) + .padding(.top, 18) + .padding(.bottom, 24) + } + .frame(maxHeight: maxHeight) + .fixedSize(horizontal: false, vertical: maxHeight == nil) + } +} + +private struct SpaceSettingsLabeledRow: View { + let title: String + @ViewBuilder let content: Content + + init(title: String, @ViewBuilder content: () -> Content) { + self.title = title + self.content = content() + } + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(title) + .frame(width: SpaceSettingsDialogMetrics.labelWidth, alignment: .trailing) + + content + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +private enum SpaceSettingsDialogMetrics { + static let labelWidth: CGFloat = 104 + static let rowSpacing: CGFloat = 10 + static let sectionSpacing: CGFloat = 16 +} diff --git a/docmostly/Features/Settings/SpaceSettingsDetailView.swift b/docmostly/Features/Settings/SpaceSettingsDetailView.swift index e35f438..f35a233 100644 --- a/docmostly/Features/Settings/SpaceSettingsDetailView.swift +++ b/docmostly/Features/Settings/SpaceSettingsDetailView.swift @@ -3,13 +3,19 @@ import SwiftUI struct SpaceSettingsDetailView: View { @State private var viewModel: SpaceSettingsViewModel let canManage: Bool + let showsCloseButton: Bool - init(space: DocmostSpace, canManage: Bool) { + init(space: DocmostSpace, canManage: Bool, showsCloseButton: Bool = false) { _viewModel = State(initialValue: SpaceSettingsViewModel(space: space)) self.canManage = canManage + self.showsCloseButton = showsCloseButton } var body: some View { - SpaceSettingsDetailFormView(viewModel: viewModel, canManage: canManage) + SpaceSettingsDetailFormView( + viewModel: viewModel, + canManage: canManage, + showsCloseButton: showsCloseButton + ) } } diff --git a/docmostly/Features/Settings/SpaceSettingsDialog.swift b/docmostly/Features/Settings/SpaceSettingsDialog.swift new file mode 100644 index 0000000..a39ef69 --- /dev/null +++ b/docmostly/Features/Settings/SpaceSettingsDialog.swift @@ -0,0 +1,29 @@ +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, + 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/Spaces/MacMainShellDetailView.swift b/docmostly/Features/Spaces/MacMainShellDetailView.swift index 4ff9b3e..0f92fc4 100644 --- a/docmostly/Features/Spaces/MacMainShellDetailView.swift +++ b/docmostly/Features/Spaces/MacMainShellDetailView.swift @@ -3,9 +3,10 @@ import SwiftUI #if os(macOS) struct MacMainShellDetailView: View { @Environment(AppState.self) private var appState + @State private var navigationPath = NavigationPath() var body: some View { - NavigationStack { + NavigationStack(path: $navigationPath) { if let selectedPageID = appState.selectedPageID { PageReaderView(pageID: selectedPageID) } else { @@ -13,9 +14,26 @@ struct MacMainShellDetailView: View { } } .navigationSplitViewColumnWidth(min: 520, ideal: 900) + .onChange(of: navigationResetKey) { _, _ in + navigationPath = NavigationPath() + } + } + + private var navigationResetKey: MacMainShellDetailNavigationResetKey { + MacMainShellDetailNavigationResetKey( + destination: appState.selectedSidebarDestination, + selectedSpaceID: appState.selectedSpaceID, + selectedPageID: appState.selectedPageID + ) } } +private struct MacMainShellDetailNavigationResetKey: Equatable { + let destination: SidebarDestination? + let selectedSpaceID: String? + let selectedPageID: String? +} + private struct MacMainShellEmptyPageDetailView: View { @Environment(AppState.self) private var appState @@ -28,12 +46,7 @@ private struct MacMainShellEmptyPageDetailView: View { case .search: SearchView() case .settings: - if let selectedSpace { - MacSpaceSettingsDetailView(space: selectedSpace) - .id(selectedSpace.id) - } else { - ContentUnavailableView("No Space Selected", systemImage: "square.stack.3d.up") - } + SettingsView() case .space, nil: if let selectedSpace { PageBrowserHomeView(space: selectedSpace) @@ -53,22 +66,4 @@ private struct MacMainShellEmptyPageDetailView: View { return appState.spaces.first } } - -private struct MacSpaceSettingsDetailView: View { - @Environment(AppState.self) private var appState - @State private var viewModel = SettingsManagementViewModel() - - let space: DocmostSpace - - var body: some View { - SpaceSettingsDetailView(space: space, canManage: canManage) - .task { - viewModel.seed(from: appState) - } - } - - private var canManage: Bool { - viewModel.canManageWorkspace || space.membership?.role == "admin" - } -} #endif diff --git a/docmostly/Features/Spaces/MacWorkspaceSidebarView.swift b/docmostly/Features/Spaces/MacWorkspaceSidebarView.swift index 91504b9..a34f2ac 100644 --- a/docmostly/Features/Spaces/MacWorkspaceSidebarView.swift +++ b/docmostly/Features/Spaces/MacWorkspaceSidebarView.swift @@ -40,9 +40,9 @@ struct MacWorkspaceSidebarView: View { MacSidebarActionRow( title: "Space settings", systemImage: "gearshape", - isSelected: selectionState.isUtilitySelected(.settings) + isSelected: false ) { - appState.selectSidebarUtilityDestination(.settings) + showSpaceSettings() } MacSidebarActionRow( @@ -255,7 +255,8 @@ struct MacWorkspaceSidebarView: View { } private func showSpaceSettings() { - appState.selectSidebarUtilityDestination(.settings) + guard selectedSpace != nil else { return } + commandController.requestSpaceSettingsPresentation() } } diff --git a/docmostly/Features/Spaces/MainShellView.swift b/docmostly/Features/Spaces/MainShellView.swift index 95928a0..c1b2c5e 100644 --- a/docmostly/Features/Spaces/MainShellView.swift +++ b/docmostly/Features/Spaces/MainShellView.swift @@ -2,6 +2,10 @@ import SwiftUI struct MainShellView: View { @Environment(AppState.self) private var appState + #if os(macOS) + @Environment(MacDesktopCommandController.self) private var commandController + @State private var isShowingSpaceSettings = false + #endif @State private var columnVisibility = NavigationSplitViewVisibility.all var body: some View { @@ -15,6 +19,20 @@ struct MainShellView: View { .task { await loadSpacesIfNeeded() } + .task(id: commandController.spaceSettingsPresentationRequestID) { + guard commandController.spaceSettingsPresentationRequestID != nil else { return } + await loadSpacesIfNeeded() + showSpaceSettings() + commandController.clearSpaceSettingsPresentationRequest() + } + .sheet(isPresented: $isShowingSpaceSettings) { + if let selectedSpace { + SpaceSettingsDialog(space: selectedSpace) + } else { + ContentUnavailableView("No Space Selected", systemImage: "square.stack.3d.up") + .frame(minWidth: 480, minHeight: 280) + } + } #else NavigationSplitView(columnVisibility: $columnVisibility) { SidebarRootView() @@ -31,6 +49,23 @@ struct MainShellView: View { } private func loadSpacesIfNeeded() async { + guard appState.spaces.isEmpty else { return } await appState.loadSpaces() } + + #if os(macOS) + private var selectedSpace: DocmostSpace? { + if let selectedSpaceID = appState.selectedSpaceID, + let space = appState.spaces.first(where: { $0.id == selectedSpaceID }) { + return space + } + + return appState.spaces.first + } + + private func showSpaceSettings() { + guard selectedSpace != nil else { return } + isShowingSpaceSettings = true + } + #endif } diff --git a/docmostlyTests/App/AppStateNavigationSelectionTests.swift b/docmostlyTests/App/AppStateNavigationSelectionTests.swift index c69b2da..5f5bbfa 100644 --- a/docmostlyTests/App/AppStateNavigationSelectionTests.swift +++ b/docmostlyTests/App/AppStateNavigationSelectionTests.swift @@ -60,6 +60,24 @@ struct AppStateNavigationSelectionTests { #expect(appState.selectedPageID == nil) } + @Test func switchingSpacesAfterOpeningAuxiliaryPageClearsStalePageSelection() { + let appState = makeAppState() + let target = PageOpenTarget( + id: "page-1", + slugId: "roadmap", + spaceId: "space-1", + revealSpaceInSidebar: false + ) + appState.selectSidebarUtilityDestination(.search) + + appState.openPage(target) + appState.selectSidebarDestination(.space("space-2")) + + #expect(appState.selectedSidebarDestination == .space("space-2")) + #expect(appState.selectedSpaceID == "space-2") + #expect(appState.selectedPageID == nil) + } + @Test func defaultSpaceSelectionDoesNotOverrideAnExistingSpace() { let appState = makeAppState() appState.selectedSpaceID = "space-2" diff --git a/docmostlyTests/PageReader/PageOpenTargetTests.swift b/docmostlyTests/PageReader/PageOpenTargetTests.swift new file mode 100644 index 0000000..4740b5c --- /dev/null +++ b/docmostlyTests/PageReader/PageOpenTargetTests.swift @@ -0,0 +1,174 @@ +import Foundation +import Testing +@testable import docmostly + +struct PageOpenTargetTests { + @Test func pageTargetUsesPageSlugAndSpace() { + let page = page(id: "page-1", slugId: "roadmap", spaceId: "space-1") + + let target = PageOpenTarget(page: page, revealSpaceInSidebar: true) + + #expect(target.id == "page-1") + #expect(target.slugId == "roadmap") + #expect(target.spaceId == "space-1") + #expect(target.revealSpaceInSidebar) + } + + @Test func browserItemTargetUsesItemRouteData() { + let item = PageBrowserItem(page: page(id: "page-1", slugId: "roadmap", spaceId: "space-1"), fallbackSpaceName: "Product") + + let target = PageOpenTarget(item: item) + + #expect(target.id == "page-1") + #expect(target.slugId == "roadmap") + #expect(target.spaceId == "space-1") + #expect(target.revealSpaceInSidebar == false) + } + + @Test func searchResultTargetUsesResultSpace() { + let result = DocmostSearchResult( + id: "result-1", + title: "Roadmap", + icon: nil, + parentPageId: nil, + slugId: "roadmap", + creatorId: nil, + createdAt: nil, + updatedAt: nil, + rank: nil, + highlight: nil, + space: SearchResultSpace(id: "space-1", name: "Product", slug: "product", icon: nil) + ) + + let target = PageOpenTarget(searchResult: result) + + #expect(target.id == "result-1") + #expect(target.slugId == "roadmap") + #expect(target.spaceId == "space-1") + } + + @Test func pageFavoriteTargetUsesNestedPageWhenAvailable() { + let favorite = DocmostFavorite( + id: "favorite-1", + userId: "user-1", + pageId: "page-id-fallback", + spaceId: nil, + templateId: nil, + type: .page, + workspaceId: "workspace-1", + createdAt: nil, + page: DocmostFavoritePage( + id: "page-1", + slugId: "roadmap", + title: "Roadmap", + icon: nil, + spaceId: "space-1" + ), + space: nil, + template: nil + ) + + let target = PageOpenTarget(favorite: favorite) + + #expect(target?.id == "page-1") + #expect(target?.slugId == "roadmap") + #expect(target?.spaceId == "space-1") + } + + @Test func nonPageFavoriteDoesNotCreatePageTarget() { + let favorite = DocmostFavorite( + id: "favorite-1", + userId: "user-1", + pageId: nil, + spaceId: "space-1", + templateId: nil, + type: .space, + workspaceId: "workspace-1", + createdAt: nil, + page: nil, + space: DocmostFavoriteSpace(id: "space-1", name: "Product", slug: "product", logo: nil), + template: nil + ) + + #expect(PageOpenTarget(favorite: favorite) == nil) + } + + @Test func notificationTargetUsesNotificationSpaceContext() { + let notification = DocmostNotification( + id: "notification-1", + userId: "user-1", + workspaceId: "workspace-1", + type: .pageUpdated, + actorId: nil, + pageId: "page-1", + spaceId: "space-1", + commentId: nil, + data: nil, + readAt: nil, + emailedAt: nil, + archivedAt: nil, + createdAt: nil, + actor: nil, + page: DocmostNotificationPage(id: "page-1", title: "Roadmap", slugId: "roadmap", icon: nil), + space: DocmostNotificationSpace(id: "space-1", name: "Product", slug: "product"), + comment: nil + ) + + let target = PageOpenTarget(notification: notification) + + #expect(target?.id == "page-1") + #expect(target?.slugId == "roadmap") + #expect(target?.spaceId == "space-1") + } + + @Test func notificationWithoutPageDoesNotCreatePageTarget() { + let notification = DocmostNotification( + id: "notification-1", + userId: "user-1", + workspaceId: "workspace-1", + type: .pageUpdated, + actorId: nil, + pageId: nil, + spaceId: "space-1", + commentId: nil, + data: nil, + readAt: nil, + emailedAt: nil, + archivedAt: nil, + createdAt: nil, + actor: nil, + page: nil, + space: DocmostNotificationSpace(id: "space-1", name: "Product", slug: "product"), + comment: nil + ) + + #expect(PageOpenTarget(notification: notification) == nil) + } + + private func page(id: String, slugId: String, spaceId: String) -> DocmostPage { + DocmostPage( + id: id, + slugId: slugId, + title: "Roadmap", + content: nil, + icon: nil, + coverPhoto: nil, + parentPageId: nil, + creatorId: nil, + spaceId: spaceId, + workspaceId: "workspace-1", + isLocked: false, + lastUpdatedById: nil, + createdAt: nil, + updatedAt: nil, + deletedAt: nil, + position: nil, + hasChildren: false, + permissions: nil, + creator: nil, + lastUpdatedBy: nil, + contributors: nil, + space: DocmostPageSpace(id: spaceId, name: "Product", slug: "product", logo: nil) + ) + } +} From 242ed34f790652b8ed402a7ed2efaf47613ab9de Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Wed, 8 Jul 2026 02:17:17 +0100 Subject: [PATCH 4/6] fix: refine editor read-only behavior and reader centering --- .../xcschemes/DocmostlyMac.xcscheme | 100 ------------------ .../Editor/NativeEditorBlockPrefix.swift | 30 ++++-- .../Editor/NativeEditorBlockRow.swift | 14 ++- .../Editor/NativeEditorBlockRowPolicy.swift | 11 ++ .../Features/PageReader/PageReaderView.swift | 2 + .../NativeEditorBlockRowPolicyTests.swift | 30 ++++++ 6 files changed, 73 insertions(+), 114 deletions(-) delete mode 100644 docmostly.xcodeproj/xcshareddata/xcschemes/DocmostlyMac.xcscheme create mode 100644 docmostly/Features/Editor/NativeEditorBlockRowPolicy.swift create mode 100644 docmostlyTests/Editor/NativeEditorBlockRowPolicyTests.swift diff --git a/docmostly.xcodeproj/xcshareddata/xcschemes/DocmostlyMac.xcscheme b/docmostly.xcodeproj/xcshareddata/xcschemes/DocmostlyMac.xcscheme deleted file mode 100644 index 74d9635..0000000 --- a/docmostly.xcodeproj/xcshareddata/xcschemes/DocmostlyMac.xcscheme +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docmostly/Features/Editor/NativeEditorBlockPrefix.swift b/docmostly/Features/Editor/NativeEditorBlockPrefix.swift index 1561097..f6ad100 100644 --- a/docmostly/Features/Editor/NativeEditorBlockPrefix.swift +++ b/docmostly/Features/Editor/NativeEditorBlockPrefix.swift @@ -2,6 +2,7 @@ import SwiftUI struct NativeEditorBlockPrefix: View { @Binding var block: NativeEditorBlock + var allowsTaskToggle = true var body: some View { Group { @@ -12,15 +13,7 @@ struct NativeEditorBlockPrefix: View { Text("\(ordinal.formatted()).") .monospacedDigit() case .taskListItem(let isChecked): - Button( - isChecked ? "Mark Incomplete" : "Mark Complete", - systemImage: isChecked ? "checkmark.circle.fill" : "circle" - ) { - block.kind = .taskListItem(isChecked: isChecked == false) - } - .labelStyle(.iconOnly) - .foregroundStyle(isChecked ? DocmostlyTheme.primary : .secondary) - .buttonStyle(.plain) + taskListPrefix(isChecked: isChecked) case .unsupported: Image(systemName: "lock") .accessibilityHidden(true) @@ -32,4 +25,23 @@ struct NativeEditorBlockPrefix: View { .foregroundStyle(.secondary) .frame(width: 28, alignment: .center) } + + @ViewBuilder + private func taskListPrefix(isChecked: Bool) -> some View { + let title = isChecked ? "Mark Incomplete" : "Mark Complete" + let systemImage = isChecked ? "checkmark.circle.fill" : "circle" + + if allowsTaskToggle { + Button(title, systemImage: systemImage) { + block.kind = .taskListItem(isChecked: isChecked == false) + } + .labelStyle(.iconOnly) + .foregroundStyle(isChecked ? DocmostlyTheme.primary : .secondary) + .buttonStyle(.plain) + } else { + Image(systemName: systemImage) + .foregroundStyle(isChecked ? DocmostlyTheme.primary : .secondary) + .accessibilityLabel(isChecked ? "Complete task" : "Incomplete task") + } + } } diff --git a/docmostly/Features/Editor/NativeEditorBlockRow.swift b/docmostly/Features/Editor/NativeEditorBlockRow.swift index dcefed1..80033cc 100644 --- a/docmostly/Features/Editor/NativeEditorBlockRow.swift +++ b/docmostly/Features/Editor/NativeEditorBlockRow.swift @@ -37,12 +37,18 @@ struct NativeEditorBlockRow: View { .draggable(block.id.uuidString) if hasVisiblePrefix { - NativeEditorBlockPrefix(block: $block) + NativeEditorBlockPrefix( + block: $block, + allowsTaskToggle: NativeEditorBlockRowPolicy.allowsTaskToggle(isReadOnly: isReadOnly) + ) .frame(width: 24, alignment: .center) } } } else if hasVisiblePrefix { - NativeEditorBlockPrefix(block: $block) + NativeEditorBlockPrefix( + block: $block, + allowsTaskToggle: NativeEditorBlockRowPolicy.allowsTaskToggle(isReadOnly: isReadOnly) + ) .frame(width: 24, alignment: .center) } @@ -145,9 +151,7 @@ struct NativeEditorBlockRow: View { } private var showsEditableTextEditor: Bool { - block.isEditable && - isReadOnly == false && - (focusedField.wrappedValue == .block(block.id) || block.text.characters.isEmpty) + NativeEditorBlockRowPolicy.showsEditableTextEditor(block: block, isReadOnly: isReadOnly) } private var showsControls: Bool { diff --git a/docmostly/Features/Editor/NativeEditorBlockRowPolicy.swift b/docmostly/Features/Editor/NativeEditorBlockRowPolicy.swift new file mode 100644 index 0000000..fe2312e --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorBlockRowPolicy.swift @@ -0,0 +1,11 @@ +import Foundation + +nonisolated enum NativeEditorBlockRowPolicy { + static func showsEditableTextEditor(block: NativeEditorBlock, isReadOnly: Bool) -> Bool { + block.isEditable && isReadOnly == false + } + + static func allowsTaskToggle(isReadOnly: Bool) -> Bool { + isReadOnly == false + } +} diff --git a/docmostly/Features/PageReader/PageReaderView.swift b/docmostly/Features/PageReader/PageReaderView.swift index 0a45b18..aa9dfdb 100644 --- a/docmostly/Features/PageReader/PageReaderView.swift +++ b/docmostly/Features/PageReader/PageReaderView.swift @@ -90,6 +90,8 @@ struct PageReaderView: View { } .padding() .frame(maxWidth: usesFullWidth ? .infinity : 900, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .center) + .animation(.spring(response: 0.28, dampingFraction: 0.9), value: usesFullWidth) .scrollTargetLayout() } .scrollPosition($scrollPosition) diff --git a/docmostlyTests/Editor/NativeEditorBlockRowPolicyTests.swift b/docmostlyTests/Editor/NativeEditorBlockRowPolicyTests.swift new file mode 100644 index 0000000..35e5a77 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorBlockRowPolicyTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing +@testable import docmostly + +struct NativeEditorBlockRowPolicyTests { + @Test func editModeShowsTextEditorForNonFocusedEditableBlocks() { + let block = NativeEditorBlock( + kind: .paragraph, + text: AttributedString("Editable body"), + alignment: .left + ) + + #expect(NativeEditorBlockRowPolicy.showsEditableTextEditor(block: block, isReadOnly: false)) + } + + @Test func readModeDoesNotShowTextEditorForEditableBlocks() { + let block = NativeEditorBlock( + kind: .paragraph, + text: AttributedString("Read-only body"), + alignment: .left + ) + + #expect(NativeEditorBlockRowPolicy.showsEditableTextEditor(block: block, isReadOnly: true) == false) + } + + @Test func readModeDisablesTaskListToggles() { + #expect(NativeEditorBlockRowPolicy.allowsTaskToggle(isReadOnly: false)) + #expect(NativeEditorBlockRowPolicy.allowsTaskToggle(isReadOnly: true) == false) + } +} From 9df615a4ea081a0af0dc51db2cb487f677f550b9 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Wed, 8 Jul 2026 02:21:16 +0100 Subject: [PATCH 5/6] fix: address PR check failures --- .../xcschemes/DocmostlyMac.xcscheme | 100 ++++++++++++++++++ .../Editor/NativeEditorWebURLPolicy.swift | 22 +++- docmostly/Features/Spaces/MainShellView.swift | 1 - .../NativeEditorEmbedPresentationTests.swift | 6 ++ .../PageReader/PageOpenTargetTests.swift | 5 +- 5 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 docmostly.xcodeproj/xcshareddata/xcschemes/DocmostlyMac.xcscheme diff --git a/docmostly.xcodeproj/xcshareddata/xcschemes/DocmostlyMac.xcscheme b/docmostly.xcodeproj/xcshareddata/xcschemes/DocmostlyMac.xcscheme new file mode 100644 index 0000000..7196797 --- /dev/null +++ b/docmostly.xcodeproj/xcshareddata/xcschemes/DocmostlyMac.xcscheme @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docmostly/Features/Editor/NativeEditorWebURLPolicy.swift b/docmostly/Features/Editor/NativeEditorWebURLPolicy.swift index 5a33a23..1b24368 100644 --- a/docmostly/Features/Editor/NativeEditorWebURLPolicy.swift +++ b/docmostly/Features/Editor/NativeEditorWebURLPolicy.swift @@ -66,8 +66,26 @@ nonisolated enum NativeEditorWebURLPolicy { } private static func sameOrigin(_ url: URL, as serverURL: URL) -> Bool { - url.scheme?.lowercased() == serverURL.scheme?.lowercased() + let urlScheme = url.scheme?.lowercased() + let serverScheme = serverURL.scheme?.lowercased() + + return urlScheme == serverScheme && url.host()?.lowercased() == serverURL.host()?.lowercased() - && url.port == serverURL.port + && effectivePort(for: url, scheme: urlScheme) == effectivePort(for: serverURL, scheme: serverScheme) + } + + private static func effectivePort(for url: URL, scheme: String?) -> Int? { + if let port = url.port { + return port + } + + switch scheme { + case "http": + return 80 + case "https": + return 443 + default: + return nil + } } } diff --git a/docmostly/Features/Spaces/MainShellView.swift b/docmostly/Features/Spaces/MainShellView.swift index c1b2c5e..d5f5bbc 100644 --- a/docmostly/Features/Spaces/MainShellView.swift +++ b/docmostly/Features/Spaces/MainShellView.swift @@ -49,7 +49,6 @@ struct MainShellView: View { } private func loadSpacesIfNeeded() async { - guard appState.spaces.isEmpty else { return } await appState.loadSpaces() } diff --git a/docmostlyTests/Editor/NativeEditorEmbedPresentationTests.swift b/docmostlyTests/Editor/NativeEditorEmbedPresentationTests.swift index 95e90be..24413e6 100644 --- a/docmostlyTests/Editor/NativeEditorEmbedPresentationTests.swift +++ b/docmostlyTests/Editor/NativeEditorEmbedPresentationTests.swift @@ -120,6 +120,12 @@ struct NativeEditorEmbedPresentationTests { serverURLString: serverURLString )?.absoluteString == "https://docs.example.com/uploads/diagram.svg" ) + #expect( + NativeEditorWebURLPolicy.documentResourceURL( + from: "https://docs.example.com:443/uploads/diagram.svg", + serverURLString: serverURLString + )?.absoluteString == "https://docs.example.com:443/uploads/diagram.svg" + ) #expect( NativeEditorWebURLPolicy.documentResourceURL( from: "https://evil.example.com/uploads/diagram.svg", diff --git a/docmostlyTests/PageReader/PageOpenTargetTests.swift b/docmostlyTests/PageReader/PageOpenTargetTests.swift index 4740b5c..3ad9873 100644 --- a/docmostlyTests/PageReader/PageOpenTargetTests.swift +++ b/docmostlyTests/PageReader/PageOpenTargetTests.swift @@ -15,7 +15,10 @@ struct PageOpenTargetTests { } @Test func browserItemTargetUsesItemRouteData() { - let item = PageBrowserItem(page: page(id: "page-1", slugId: "roadmap", spaceId: "space-1"), fallbackSpaceName: "Product") + let item = PageBrowserItem( + page: page(id: "page-1", slugId: "roadmap", spaceId: "space-1"), + fallbackSpaceName: "Product" + ) let target = PageOpenTarget(item: item) From bb202b82fea190facf4ff88e44481e18848909c1 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Wed, 8 Jul 2026 02:27:45 +0100 Subject: [PATCH 6/6] fix: require fresh spaces for settings --- docmostly/App/AppState.swift | 22 ++++++++++--------- docmostly/Features/Spaces/MainShellView.swift | 6 +++-- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/docmostly/App/AppState.swift b/docmostly/App/AppState.swift index 21d7d0f..66acfa4 100644 --- a/docmostly/App/AppState.swift +++ b/docmostly/App/AppState.swift @@ -30,7 +30,7 @@ final class AppState { @ObservationIgnored var cacheScope: CacheScope? @ObservationIgnored private(set) var apiClient: DocmostAPIClient? @ObservationIgnored private var restoreTask: Task? - @ObservationIgnored private var spacesLoadTask: Task? + @ObservationIgnored private var spacesLoadTask: Task? @ObservationIgnored private var pendingCacheWrites: [CacheWriteOperation] = [] @ObservationIgnored private var cacheWriteTask: Task? @ObservationIgnored var offlineReplayTask: Task? @@ -216,25 +216,25 @@ final class AppState { phase = .unauthenticated } - func loadSpaces() async { + @discardableResult func loadSpaces() async -> Bool { if let spacesLoadTask { - await spacesLoadTask.value - return + return await spacesLoadTask.value } let task = Task { [weak self] in - guard let self else { return } - await self.performLoadSpaces() + guard let self else { return false } + return await self.performLoadSpaces() } spacesLoadTask = task - await task.value + let loadedFromServer = await task.value spacesLoadTask = nil + return loadedFromServer } - private func performLoadSpaces() async { + private func performLoadSpaces() async -> Bool { guard let apiClient else { await loadCachedSpaces() - return + return false } do { @@ -247,14 +247,16 @@ final class AppState { } await refreshOfflineMutationCount() scheduleOfflineQueueReconciliation() + return true } catch { isOffline = true statusMessage = error.localizedDescription guard canUseOfflineCache(after: error) else { spaces = [] - return + return false } await loadCachedSpaces() + return false } } diff --git a/docmostly/Features/Spaces/MainShellView.swift b/docmostly/Features/Spaces/MainShellView.swift index d5f5bbc..ea95507 100644 --- a/docmostly/Features/Spaces/MainShellView.swift +++ b/docmostly/Features/Spaces/MainShellView.swift @@ -21,9 +21,11 @@ struct MainShellView: View { } .task(id: commandController.spaceSettingsPresentationRequestID) { guard commandController.spaceSettingsPresentationRequestID != nil else { return } - await loadSpacesIfNeeded() + defer { + commandController.clearSpaceSettingsPresentationRequest() + } + guard await appState.loadSpaces() else { return } showSpaceSettings() - commandController.clearSpaceSettingsPresentationRequest() } .sheet(isPresented: $isShowingSpaceSettings) { if let selectedSpace {