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/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..5f38417 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 */; @@ -254,6 +265,7 @@ ); name = docmostly; packageProductDependencies = ( + E03B35CC5DAE0817D7846F6B /* YouTubePlayerKit */, ); productName = docmostly; productReference = 47D95DD22FE294E500ECEA87 /* docmostly.app */; @@ -348,6 +360,9 @@ ); mainGroup = 47D95DC92FE294E500ECEA87; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 29CBB455D32F07D0CAD26246 /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 47D95DD32FE294E500ECEA87 /* Products */; projectDirPath = ""; @@ -492,13 +507,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 +544,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 +992,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.xcodeproj/xcshareddata/xcschemes/DocmostlyMac.xcscheme b/docmostly.xcodeproj/xcshareddata/xcschemes/DocmostlyMac.xcscheme index 74d9635..7196797 100644 --- a/docmostly.xcodeproj/xcshareddata/xcschemes/DocmostlyMac.xcscheme +++ b/docmostly.xcodeproj/xcshareddata/xcschemes/DocmostlyMac.xcscheme @@ -54,8 +54,8 @@ ? - @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/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..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 { @@ -9,22 +10,10 @@ 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", - systemImage: isChecked ? "checkmark.circle.fill" : "circle" - ) { - block.kind = .taskListItem(isChecked: isChecked == false) - } - .labelStyle(.iconOnly) - .foregroundStyle(isChecked ? DocmostlyTheme.primary : .secondary) - case .blockquote: - Image(systemName: "quote.opening") - .accessibilityHidden(true) - case .codeBlock: - Image(systemName: "curlybraces") - .accessibilityHidden(true) + taskListPrefix(isChecked: isChecked) case .unsupported: Image(systemName: "lock") .accessibilityHidden(true) @@ -34,6 +23,25 @@ struct NativeEditorBlockPrefix: View { } .font(.body) .foregroundStyle(.secondary) - .padding(.top, 10) + .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 1e4d395..80033cc 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( @@ -36,38 +37,49 @@ 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) } 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 +87,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 +107,7 @@ struct NativeEditorBlockRow: View { .foregroundStyle(.secondary) } } - .padding(.vertical, 2) + .padding(.vertical, rowVerticalPadding) .padding(.leading, blockIndentPadding) .background { (isSelected ? DocmostlyTheme.primaryTint : Color.clear) @@ -138,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 { @@ -149,7 +160,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 +170,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/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/Editor/NativeEditorBlockSurfaces.swift b/docmostly/Features/Editor/NativeEditorBlockSurfaces.swift new file mode 100644 index 0000000..e948724 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorBlockSurfaces.swift @@ -0,0 +1,492 @@ +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), + allowedHosts: Set([sourceURL.host()?.lowercased()].compactMap(\.self)) + ) + .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? { + NativeEditorWebURLPolicy.documentResourceURL( + from: diagram.source, + serverURLString: serverURLString + ) + } +} + +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..afea4b9 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorEmbedBlockView.swift @@ -0,0 +1,279 @@ +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) + .id(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"), + allowedHosts: ["open.spotify.com"] + ) + .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? { + NativeEditorWebURLPolicy.webURL(from: embed.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 + let allowedHosts: Set + + func makeCoordinator() -> 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 + return webView + } + + 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(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: 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 new file mode 100644 index 0000000..f3ccb44 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorEmbedPresentation.swift @@ -0,0 +1,135 @@ +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 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? { + 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 youtubeVideo(from: source) != nil { + return "YouTube" + } + + if spotifyEmbedURL(from: source) != nil { + return "Spotify" + } + + return nil + } + + private static func youtubeVideo(from source: String?) -> YouTubeVideo? { + 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())).map { + YouTubeVideo(id: $0, timeQueryItems: youtubeTimeQueryItems(from: components)) + } + } + + guard normalizedHost == "youtube.com" || normalizedHost == "youtube-nocookie.com" else { + return nil + } + + if components.path.hasPrefix("/embed/") { + 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) + .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/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/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/Editor/NativeEditorWebURLPolicy.swift b/docmostly/Features/Editor/NativeEditorWebURLPolicy.swift new file mode 100644 index 0000000..1b24368 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorWebURLPolicy.swift @@ -0,0 +1,91 @@ +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 { + let urlScheme = url.scheme?.lowercased() + let serverScheme = serverURL.scheme?.lowercased() + + return urlScheme == serverScheme + && url.host()?.lowercased() == serverURL.host()?.lowercased() + && 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/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/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/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/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/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..aa9dfdb 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 ) @@ -89,10 +90,14 @@ 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) + #if os(iOS) .safeAreaPadding(.bottom, 72) + #endif .navigationTitle(navigationChromeTitle) #if os(iOS) .navigationBarTitleDisplayMode(.inline) 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 90a9eb6..ea95507 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 { @@ -13,7 +17,23 @@ struct MainShellView: View { } .navigationSplitViewStyle(.balanced) .task { - await appState.loadSpaces() + await loadSpacesIfNeeded() + } + .task(id: commandController.spaceSettingsPresentationRequestID) { + guard commandController.spaceSettingsPresentationRequestID != nil else { return } + defer { + commandController.clearSpaceSettingsPresentationRequest() + } + guard await appState.loadSpaces() else { return } + showSpaceSettings() + } + .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) { @@ -25,8 +45,28 @@ struct MainShellView: View { } .navigationSplitViewStyle(.balanced) .task { - await appState.loadSpaces() + await loadSpacesIfNeeded() } #endif } + + private func loadSpacesIfNeeded() async { + 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/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) + } +} diff --git a/docmostlyTests/Editor/NativeEditorEmbedPresentationTests.swift b/docmostlyTests/Editor/NativeEditorEmbedPresentationTests.swift new file mode 100644 index 0000000..24413e6 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorEmbedPresentationTests.swift @@ -0,0 +1,160 @@ +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") + } + + @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://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", + 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", + 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") diff --git a/docmostlyTests/PageReader/PageOpenTargetTests.swift b/docmostlyTests/PageReader/PageOpenTargetTests.swift new file mode 100644 index 0000000..3ad9873 --- /dev/null +++ b/docmostlyTests/PageReader/PageOpenTargetTests.swift @@ -0,0 +1,177 @@ +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) + ) + } +}