Add Mac Home and document windows with native commands
Separate launcher and editor lifetimes, add account-scoped recent documents, preserve native selection during CRDT renders, and align document typography and zoom with the web fixture.
Co-Authored-By: Codex GPT-6 <noreply@openai.com>
35 files changed,
+1224
−48
docs/superpowers/specs/2026-09-20-mac-document-windows.md+46 −0native/apple/Package.swift+1 −0native/apple/Sources/DocsApp/DocsAccountWindow.swift+24 −0native/apple/Sources/DocsApp/DocsApp.swift+17 −5native/apple/Sources/DocsApp/DocsDocumentWindow.swift+23 −0native/apple/Sources/DocsApp/DocsWindowCommands.swift+32 −0native/apple/Sources/DocsApp/DocsWindows.swift+47 −0native/apple/Sources/DocsKit/DocsDocumentMenu.swift+26 −0native/apple/Sources/DocsKit/DocsEditorCommands.swift+58 −0native/apple/Sources/DocsKit/DocsEditorContext.swift+20 −0native/apple/Sources/DocsKit/DocsMacStrings.swift+5 −0native/apple/Sources/DocsKit/DocsModel.swift+7 −6native/apple/Sources/DocsKit/DocsPagedTextView.swift+3 −2native/apple/Sources/DocsKit/DocsPaging.swift+7 −1native/apple/Sources/DocsKit/DocsWorkspace.swift+133 −29native/apple/Sources/DocsKit/DocumentExportControls.swift+9 −0native/apple/Sources/DocsKit/DocumentRenderReconciliation.swift+21 −0native/apple/Sources/DocsKit/DocumentThumbnail.swift+3 −1native/apple/Sources/DocsKit/NativeDocumentTextView.swift+16 −2native/apple/Sources/DocsKit/NativeTextProjection.swift+15 −2native/apple/Sources/DocsKit/Resources/ar.lproj/Mac.strings+49 −0native/apple/Sources/DocsKit/Resources/bn.lproj/Mac.strings+49 −0native/apple/Sources/DocsKit/Resources/en.lproj/Mac.strings+49 −0native/apple/Sources/DocsKit/Resources/es.lproj/Mac.strings+49 −0native/apple/Sources/DocsKit/Resources/ga.lproj/Mac.strings+49 −0native/apple/Sources/DocsKit/Resources/hi.lproj/Mac.strings+49 −0native/apple/Sources/DocsKit/Resources/ja.lproj/Mac.strings+49 −0native/apple/Sources/DocsKit/Resources/pt.lproj/Mac.strings+49 −0native/apple/Sources/DocsKit/Resources/ru.lproj/Mac.strings+49 −0native/apple/Sources/DocsKit/Resources/vi.lproj/Mac.strings+49 −0native/apple/Sources/DocsKit/Resources/yue.lproj/Mac.strings+49 −0native/apple/Sources/DocsKit/Resources/zh-Hans.lproj/Mac.strings+49 −0native/apple/Tests/DocsKitTests/DocumentRenderReconciliationTests.swift+49 −0native/apple/Tests/DocsKitTests/DocumentTypographyTests.swift+47 −0native/apple/Tests/DocsWindowTests/WindowRoutingTests.swift+27 −0
diff --git a/docs/superpowers/specs/2026-09-20-mac-document-windows.md b/docs/superpowers/specs/2026-09-20-mac-document-windows.md| new file mode 100644 |
| index 0000000..13d65e5 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-09-20-mac-document-windows.md |
| @@ -0,0 +1,46 @@ |
| +# Mac document windows — 2026-09-20 |
| + |
| +Paul requests a separate Home launcher and document windows for Docs and Sheets. |
| +This extends the native Apple specs; editors remain native and server-backed. |
| + |
| +Home is a singleton with a Recent sidebar and searchable thumbnail grid. Templates |
| +are deferred until template creation exists. Each open document has an independent |
| +model, live stream, selection and save queue. Window identity includes the account |
| +and document; opening an existing document focuses it. Document/session state remains in memory; credentials retain the existing |
| +Keychain handling and are never encoded in scene restoration values. Missing restored sessions return |
| +the user to Home. File > Recent and Window > Home remain available with no editor. |
| +Recent entries are held in memory for this iteration, scoped to the account session. |
| + |
| +Mac document windows have no app navigation sidebar. Existing editing and file |
| +actions move into menu-bar commands targeted at the focused scene. Standard native |
| +text responder actions remain available in text fields. Docs adds a document-only |
| +inspector and zoom starting at 100%; zoom changes display scale, never text wrapping. |
| +No server page-settings API exists: inspector exposes existing personal layout |
| +settings and document information, without pretending to persist new paper settings. |
| + |
| +Sheets edits in the selected cell, preserving drafts on failed save and respecting |
| +existing write serialization. Header buttons select whole rows/columns. Double-click |
| +on column boundaries autosizes using displayed text/font measurements and existing |
| +size endpoint; menu alternative provided. Remove only the grid's outer focus effect, |
| +retain cell selection and keyboard focus. Enter/Tab commit; Escape discards. |
| + |
| +Docs rendering investigation compares web CSS/font and page geometry against TextKit. |
| +Use identical bundled fonts, logical width, sizes, paragraph spacing and line height. |
| +Native optimistic edits must not be overwritten by stale projection generations or |
| +IME composition. Avoid unnecessary complete attributed storage replacement and |
| +selection resets; preserve model render acknowledgement. Exact cross-engine layout |
| +parity requires measured fixture verification, not a compilation claim. |
| + |
| +Validate Mac builds, existing native tests and focused regression cases; exercise |
| +window/menu targeting and editing where UI automation is available. Build iOS to |
| +check shared code. No merge, deployment or app publication is part of this change. |
| + |
| +Review: GPT-6 Astra (high), 2026-09-20. Required invariants incorporated: |
| +window keys combine canonical origin, account identity and document identity; |
| +clients are immutable per window. Sign out is unavailable until that account's |
| +windows close; switching the launcher account leaves those windows intact. |
| +Close/quit guards cover in-flight, queued, failed and composing edits. Window |
| +registry lifecycle ends only after the actual guarded close. Edit commands obey |
| +the first responder, not merely the focused scene. Render reconciliation compares |
| +actual optimistic text storage, updates CRDT metadata and acknowledges visually |
| +equal generations. Zoom keeps logical layout independent of magnified viewport. |
diff --git a/native/apple/Package.swift b/native/apple/Package.swift| index ab1935f..b1be08e 100644 |
| --- a/native/apple/Package.swift |
| +++ b/native/apple/Package.swift |
| @@ -13,4 +13,5 @@ let package = Package(name: "DocsNative", defaultLocalization: "en", platforms: |
| .target(name: "DocsKit", dependencies: ["DocsCRDT", .product(name: "RastrilloApple", package: "native")], resources: [.process("Resources")]), |
| .executableTarget(name: "DocsMac", dependencies: [.product(name: "RastrilloMacUpdates", package: "native", condition: .when(platforms: [.macOS])), "DocsKit", .product(name: "RastrilloApple", package: "native")], path: "Sources/DocsApp"), |
| .testTarget(name: "DocsKitTests", dependencies: ["DocsKit"]), |
| + .testTarget(name: "DocsWindowTests", dependencies: ["DocsMac", .product(name: "RastrilloApple", package: "native")]), |
| ]) |
diff --git a/native/apple/Sources/DocsApp/DocsAccountWindow.swift b/native/apple/Sources/DocsApp/DocsAccountWindow.swift| index 1be032d..4438cef 100644 |
| --- a/native/apple/Sources/DocsApp/DocsAccountWindow.swift |
| +++ b/native/apple/Sources/DocsApp/DocsAccountWindow.swift |
| @@ -6,14 +6,38 @@ import SwiftUI |
| /// one window must never discard another window's unconfirmed edits. |
| struct DocsAccountWindow: View { |
| @StateObject private var connection = NativeConnection(defaultServer: "https://docs.oficina.works") |
| + #if os(macOS) |
| + @Bindable var windows: DocsWindows |
| + @Environment(\.openWindow) private var openWindow |
| + #endif |
| var body: some View { |
| Group { |
| if let client = connection.client { |
| + #if os(macOS) |
| + DocsWorkspace(client: client, accounts: connection.showAccounts, signOut: { |
| + guard windows.canSignOut(connection.activeAccountID) else { return } |
| + windows.entries.removeAll { $0.account == connection.activeAccountID } |
| + connection.signOut() |
| + }, canSignOut: windows.canSignOut(connection.activeAccountID), createRequested: $windows.createRequested, openDocument: { document, title in |
| + guard let account = connection.activeAccountID else { return } |
| + let id = windows.reserve(account: account, document: document, title: title, client: client) |
| + openWindow(id: "document", value: id) |
| + }, updateRecents: { documents in |
| + guard let account = connection.activeAccountID else { return } |
| + windows.updateRecents(documents, account: account, client: client) |
| + }) |
| + .id(connection.activeAccountID) |
| + #else |
| DocsWorkspace(client: client, accounts: connection.showAccounts, signOut: connection.signOut) |
| .id(connection.activeAccountID) |
| + #endif |
| } else { |
| NativeAccountsView("Oficina Docs", systemImage: "doc.text", connection: connection) |
| } |
| }.onOpenURL(perform: connection.handleOpenURL) |
| + #if os(macOS) |
| + .focusedSceneValue(\.docsWindowAccount, connection.activeAccountID) |
| + .onChange(of: connection.activeAccountID, initial: true) { _, value in windows.activeAccount = value } |
| + #endif |
| } |
| } |
diff --git a/native/apple/Sources/DocsApp/DocsApp.swift b/native/apple/Sources/DocsApp/DocsApp.swift| index 8f95341..e11d2f0 100644 |
| --- a/native/apple/Sources/DocsApp/DocsApp.swift |
| +++ b/native/apple/Sources/DocsApp/DocsApp.swift |
| @@ -9,15 +9,27 @@ import SwiftUI |
| @State private var updater = NativeUpdater() |
| #endif |
| #if os(macOS) |
| + @State private var windows = DocsWindows() |
| @NSApplicationDelegateAdaptor(DocsApplicationDelegate.self) private var appDelegate |
| #endif |
| var body: some Scene { |
| - WindowGroup { DocsAccountWindow() } |
| - #if os(macOS) |
| - .defaultSize(width: 1120, height: 820) |
| + #if os(macOS) |
| + Window(docsMacString("Home"), id: "home") { DocsAccountWindow(windows: windows) } |
| + .defaultSize(width: 1000, height: 760) |
| + .commands { |
| + DocsWindowCommands(windows: windows) |
| + DocsEditorCommands() |
| #if canImport(RastrilloMacUpdates) |
| - .commands { NativeUpdateCommands(updater: updater) } |
| - #endif |
| + NativeUpdateCommands(updater: updater) |
| #endif |
| + } |
| + WindowGroup(docsMacString("Docs"), id: "document", for: UUID.self) { $id in |
| + DocsDocumentWindow(windows: windows, id: id) |
| } |
| + .defaultSize(width: 1120, height: 820) |
| + .restorationBehavior(.disabled) |
| + #else |
| + WindowGroup { DocsAccountWindow() } |
| + #endif |
| + } |
| } |
diff --git a/native/apple/Sources/DocsApp/DocsDocumentWindow.swift b/native/apple/Sources/DocsApp/DocsDocumentWindow.swift| new file mode 100644 |
| index 0000000..7c8e090 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsApp/DocsDocumentWindow.swift |
| @@ -0,0 +1,23 @@ |
| +#if os(macOS) |
| +import DocsKit |
| +import SwiftUI |
| + |
| +struct DocsDocumentWindow: View { |
| + let windows: DocsWindows |
| + let id: UUID? |
| + @Environment(\.openWindow) private var openWindow |
| + var body: some View { |
| + if let entry = windows.entries.first(where: { $0.id == id }) { |
| + DocsWorkspace(client: entry.client, signOut: {}, documentID: entry.document) |
| + .id(entry.id) |
| + .focusedSceneValue(\.docsWindowAccount, entry.account) |
| + .onAppear { windows.setOpen(entry.id, true) } |
| + .onDisappear { windows.setOpen(entry.id, false) } |
| + } else { |
| + ContentUnavailableView { Label(docsMacString("Open a document from Home"), systemImage: "house") } actions: { |
| + Button(docsMacString("Home")) { openWindow(id: "home") } |
| + } |
| + } |
| + } |
| +} |
| +#endif |
diff --git a/native/apple/Sources/DocsApp/DocsWindowCommands.swift b/native/apple/Sources/DocsApp/DocsWindowCommands.swift| new file mode 100644 |
| index 0000000..db8eaa2 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsApp/DocsWindowCommands.swift |
| @@ -0,0 +1,32 @@ |
| +#if os(macOS) |
| +import SwiftUI |
| +import DocsKit |
| + |
| +struct DocsWindowCommands: Commands { |
| + let windows: DocsWindows |
| + @FocusedValue(\.docsWindowAccount) private var account |
| + @Environment(\.openWindow) private var openWindow |
| + var body: some Commands { |
| + CommandGroup(replacing: .newItem) { |
| + Button(docsMacString("New document")) { |
| + windows.createRequested = true |
| + openWindow(id: "home") |
| + }.keyboardShortcut("n") |
| + Menu(docsMacString("Recent")) { |
| + ForEach(windows.entries.filter { $0.account == (account ?? windows.activeAccount) }.prefix(20)) { entry in |
| + Button(entry.title) { |
| + windows.setOpen(entry.id, true) |
| + openWindow(id: "document", value: entry.id) |
| + } |
| + } |
| + if !windows.entries.contains(where: { $0.account == (account ?? windows.activeAccount) }) { |
| + Text(docsMacString("No recent documents")) |
| + } |
| + } |
| + Divider() |
| + DocsDocumentMenu() |
| + } |
| + |
| + } |
| +} |
| +#endif |
diff --git a/native/apple/Sources/DocsApp/DocsWindows.swift b/native/apple/Sources/DocsApp/DocsWindows.swift| new file mode 100644 |
| index 0000000..2e80e54 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsApp/DocsWindows.swift |
| @@ -0,0 +1,47 @@ |
| +#if os(macOS) |
| +import Foundation |
| +import Observation |
| +import RastrilloApple |
| +import SwiftUI |
| + |
| +/// Scene values are opaque IDs, never credentials. Reserve before opening a scene. |
| +@MainActor @Observable final class DocsWindows { |
| + struct Entry: Identifiable { |
| + let id: UUID |
| + let account: String |
| + let document: String |
| + let client: NativeHTTP |
| + var title: String |
| + var isOpen = false |
| + } |
| + var entries: [Entry] = [] |
| + var activeAccount: String? |
| + var createRequested = false |
| + func reserve(account: String, document: String, title: String, client: NativeHTTP) -> UUID { |
| + if let index = entries.firstIndex(where: { $0.account == account && $0.client.origin == client.origin && $0.document == document }) { |
| + var entry = entries.remove(at: index) |
| + entry.title = title |
| + entry.isOpen = true |
| + entries.insert(entry, at: 0) |
| + return entry.id |
| + } |
| + let entry = Entry(id: UUID(), account: account, document: document, client: client, title: title, isOpen: true) |
| + entries.insert(entry, at: 0) |
| + return entry.id |
| + } |
| + func updateRecents(_ documents: [(String, String)], account: String, client: NativeHTTP) { |
| + for (document, title) in documents { |
| + if let index = entries.firstIndex(where: { $0.account == account && $0.client.origin == client.origin && $0.document == document }) { |
| + entries[index].title = title |
| + } else { |
| + entries.append(Entry(id: UUID(), account: account, document: document, client: client, title: title)) |
| + } |
| + } |
| + } |
| + func setOpen(_ id: UUID, _ value: Bool) { |
| + if let index = entries.firstIndex(where: { $0.id == id }) { entries[index].isOpen = value } |
| + } |
| + func canSignOut(_ account: String?) -> Bool { !entries.contains { $0.account == account && $0.isOpen } } |
| +} |
| +extension FocusedValues { @Entry var docsWindowAccount: String? } |
| +#endif |
diff --git a/native/apple/Sources/DocsKit/DocsDocumentMenu.swift b/native/apple/Sources/DocsKit/DocsDocumentMenu.swift| new file mode 100644 |
| index 0000000..11188b0 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/DocsDocumentMenu.swift |
| @@ -0,0 +1,26 @@ |
| +#if os(macOS) |
| +import SwiftUI |
| + |
| +public struct DocsDocumentMenu: View { |
| + @FocusedValue(\.docsEditor) private var editor |
| + public init() {} |
| + public var body: some View { |
| + Group { |
| + Button(docsMacString("Save")) { if let editor { Task { _ = await editor.model.saveAll() } } } |
| + .keyboardShortcut("s").disabled(editor == nil) |
| + Menu(docsMacString("Export")) { |
| + Button(docsMacString("PDF…")) { editor?.exportRequest.wrappedValue = "pdf" } |
| + Button(docsMacString("Markdown…")) { editor?.exportRequest.wrappedValue = "markdown" } |
| + }.disabled(editor == nil) |
| + Button(docsMacString("Share…")) { |
| + if let editor { Task { if await editor.model.saveAll() { editor.sharing.wrappedValue = true } } } |
| + }.disabled(editor?.model.detail?.can_share != true) |
| + |
| + Divider() |
| + |
| + Button(docsMacString("Print…")) { editor?.exportRequest.wrappedValue = "print" }.keyboardShortcut("p").disabled(editor == nil) |
| + |
| + } |
| + } |
| +} |
| +#endif |
diff --git a/native/apple/Sources/DocsKit/DocsEditorCommands.swift b/native/apple/Sources/DocsKit/DocsEditorCommands.swift| new file mode 100644 |
| index 0000000..91c1104 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/DocsEditorCommands.swift |
| @@ -0,0 +1,58 @@ |
| +#if os(macOS) |
| +import AppKit |
| +import SwiftUI |
| + |
| +public struct DocsEditorCommands: Commands { |
| + @FocusedValue(\.docsEditor) private var editor |
| + public init() {} |
| + public var body: some Commands { |
| + |
| + CommandGroup(replacing: .printItem) {} |
| + |
| + CommandMenu(docsMacString("Format")) { |
| + Group { |
| + Button(docsMacString("Bold")) { editor?.actions?.perform("bold") }.keyboardShortcut("b") |
| + Button(docsMacString("Italic")) { editor?.actions?.perform("italic") }.keyboardShortcut("i") |
| + Button(docsMacString("Inline code")) { editor?.actions?.perform("code") } |
| + Menu(docsMacString("Paragraph style")) { |
| + Button(docsMacString("Normal")) { editor?.actions?.block("paragraph") } |
| + ForEach(1...6, id: \.self) { level in |
| + Button(String(format: docsMacString("Heading %d"), level)) { editor?.actions?.block("heading", level: level) } |
| + } |
| + Button(docsMacString("Bullet list")) { editor?.actions?.block("bullet_list") } |
| + Button(docsMacString("Numbered list")) { editor?.actions?.block("ordered_list") } |
| + Button(docsMacString("Block quote")) { editor?.actions?.block("blockquote") } |
| + } |
| + Menu(docsMacString("Font")) { |
| + ForEach(["sans", "serif", "mono"], id: \.self) { family in |
| + Button(String(localized: String.LocalizationValue("docs.editor.toolbar.font." + family), bundle: DocsResources.bundle)) { editor?.actions?.format("font_" + family, exclusive: ["font_sans", "font_serif", "font_mono"]) } |
| + } |
| + } |
| + Menu(docsMacString("Size")) { |
| + ForEach([8, 9, 10, 11, 12, 14, 18, 24, 36, 48, 72], id: \.self) { size in |
| + Button(size.formatted()) { editor?.actions?.format("size_\(size)", exclusive: [8,9,10,11,12,14,18,24,36,48,72].map { "size_\($0)" } + ["size_sm", "size_lg", "size_xl"]) } |
| + } |
| + } |
| + Menu(docsMacString("Alignment")) { |
| + ForEach(["left", "center", "right"], id: \.self) { value in |
| + Button(String(localized: String.LocalizationValue("docs.editor.toolbar.align." + value), bundle: DocsResources.bundle)) { editor?.actions?.blockAttribute("align", value: value == "left" ? nil : value) } |
| + } |
| + } |
| + Button(docsMacString("Indent")) { editor?.actions?.perform("indent") } |
| + Button(docsMacString("Outdent")) { editor?.actions?.perform("outdent") } |
| + Button(docsMacString("Clear formatting")) { editor?.actions?.perform("clear-format") } |
| + }.disabled(editor?.actions?.isEnabled != true || !(NSApp.keyWindow?.firstResponder is DocsTextView)) |
| + } |
| + CommandGroup(after: .toolbar) { |
| + Menu(docsMacString("Zoom")) { |
| + ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 2.0], id: \.self) { value in |
| + Button { editor?.zoom.wrappedValue = value } label: { Text(value, format: .percent) } |
| + } |
| + }.disabled(editor == nil) |
| + Button(docsMacString("Actual size")) { editor?.zoom.wrappedValue = 1 }.keyboardShortcut("0").disabled(editor == nil) |
| + Button(docsMacString("Document inspector")) { editor?.inspecting.wrappedValue.toggle() }.disabled(editor == nil) |
| + if let editor { Toggle(docsMacString("Pages"), isOn: editor.paged) } |
| + } |
| + } |
| +} |
| +#endif |
diff --git a/native/apple/Sources/DocsKit/DocsEditorContext.swift b/native/apple/Sources/DocsKit/DocsEditorContext.swift| new file mode 100644 |
| index 0000000..4f54515 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/DocsEditorContext.swift |
| @@ -0,0 +1,20 @@ |
| +#if os(macOS) |
| +import AppKit |
| +import RastrilloApple |
| +import SwiftUI |
| + |
| +struct DocsEditorContext { |
| + let model: DocsModel |
| + let client: NativeHTTP |
| + let zoom: Binding<Double> |
| + let paged: Binding<Bool> |
| + let inspecting: Binding<Bool> |
| + let sharing: Binding<Bool> |
| + let exportRequest: Binding<String?> |
| + @MainActor var actions: DocumentCommands? { |
| + guard NSApp.keyWindow?.firstResponder is DocsTextView, let projection = model.projection else { return nil } |
| + return DocumentCommands(model: model, content: NativeTextProjection.render(projection)) |
| + } |
| +} |
| +extension FocusedValues { @Entry var docsEditor: DocsEditorContext? } |
| +#endif |
diff --git a/native/apple/Sources/DocsKit/DocsMacStrings.swift b/native/apple/Sources/DocsKit/DocsMacStrings.swift| new file mode 100644 |
| index 0000000..d93cae4 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/DocsMacStrings.swift |
| @@ -0,0 +1,5 @@ |
| +import Foundation |
| + |
| +public func docsMacString(_ key: String) -> String { |
| + NSLocalizedString(key, tableName: "Mac", bundle: DocsResources.bundle, comment: "") |
| +} |
diff --git a/native/apple/Sources/DocsKit/DocsModel.swift b/native/apple/Sources/DocsKit/DocsModel.swift| index 623479d..f7a2b86 100644 |
| --- a/native/apple/Sources/DocsKit/DocsModel.swift |
| +++ b/native/apple/Sources/DocsKit/DocsModel.swift |
| @@ -39,8 +39,8 @@ import RastrilloApple |
| documents = response.documents; canCreate = response.can_create |
| } catch { errorKey = "docs.error.generic" } |
| } |
| - func create(title: String) async { |
| - guard !creatingDocument, !hasUnsavedChanges else { return } |
| + @discardableResult func create(title: String, openCreated: Bool = true) async -> DocumentSummary? { |
| + guard !creatingDocument, !hasUnsavedChanges else { return nil } |
| let lifetime = self.lifetime |
| creatingDocument = true |
| defer { creatingDocument = false } |
| @@ -49,11 +49,12 @@ import RastrilloApple |
| let (data, status) = try await client.response("/documents", method: "POST", body: Data((form.percentEncodedQuery ?? "").replacingOccurrences(of: "+", with: "%2B").utf8), contentType: "application/x-www-form-urlencoded") |
| guard status == 201 else { throw CRDTReplica.Failure.rejected } |
| let item = try JSONDecoder().decode(DocumentSummary.self, from: data) |
| - guard self.lifetime == lifetime else { return } |
| + guard self.lifetime == lifetime else { return nil } |
| await load() |
| - guard self.lifetime == lifetime else { return } |
| - await open(item.sqid) |
| - } catch { errorKey = "docs.error.generic" } |
| + guard self.lifetime == lifetime else { return nil } |
| + if openCreated { await open(item.sqid) } |
| + return item |
| + } catch { errorKey = "docs.error.generic"; return nil } |
| } |
| func open(_ sqid: String) async { |
| guard !hasUnsavedChanges else { errorKey = "native.pending"; return } |
diff --git a/native/apple/Sources/DocsKit/DocsPagedTextView.swift b/native/apple/Sources/DocsKit/DocsPagedTextView.swift| index 2acd1a5..79e49ff 100644 |
| --- a/native/apple/Sources/DocsKit/DocsPagedTextView.swift |
| +++ b/native/apple/Sources/DocsKit/DocsPagedTextView.swift |
| @@ -8,6 +8,7 @@ import AppKit |
| override init(frame frameRect: NSRect, textContainer container: NSTextContainer?) { |
| let storage = NSTextStorage() |
| let manager = NSLayoutManager() |
| + manager.usesFontLeading = false |
| let pages = DocsPageTextContainer(size: CGSize(width: max(320, frameRect.width), height: 1_000_000)) |
| pages.lineFragmentPadding = 0 |
| storage.addLayoutManager(manager) |
| @@ -27,7 +28,7 @@ import AppKit |
| let manager = layoutManager else { return } |
| updatingGeometry = true |
| defer { updatingGeometry = false } |
| - let width = max(160, enclosingScrollView?.contentSize.width ?? bounds.width) |
| + let width = max(636, enclosingScrollView?.contentView.bounds.width ?? bounds.width) |
| let size = CGSize(width: width, height: 1_000_000) |
| if pages.size != size { pages.size = size } |
| pages.geometry = DocsPageGeometry(viewportWidth: width, paged: paged) |
| @@ -36,7 +37,6 @@ import AppKit |
| if abs(frame.width - width) > 0.5 || abs(frame.height - height) > 0.5 { |
| setFrameSize(CGSize(width: width, height: height)) |
| } |
| - needsDisplay = true |
| } |
| override func draw(_ dirtyRect: NSRect) { |
| NSColor.windowBackgroundColor.setFill() |
| @@ -70,6 +70,7 @@ import UIKit |
| override init(frame: CGRect = .zero, textContainer: NSTextContainer? = nil) { |
| let storage = NSTextStorage() |
| let manager = NSLayoutManager() |
| + manager.usesFontLeading = false |
| let pages = DocsPageTextContainer(size: CGSize(width: max(320, frame.width), height: 1_000_000)) |
| pages.lineFragmentPadding = 0 |
| storage.addLayoutManager(manager) |
diff --git a/native/apple/Sources/DocsKit/DocsPaging.swift b/native/apple/Sources/DocsKit/DocsPaging.swift| index aadca0f..c04ef52 100644 |
| --- a/native/apple/Sources/DocsKit/DocsPaging.swift |
| +++ b/native/apple/Sources/DocsKit/DocsPaging.swift |
| @@ -14,7 +14,13 @@ struct DocsPageGeometry: Equatable, Sendable { |
| static let paperHeight: CGFloat = 792 |
| static let verticalMargin: CGFloat = 72 |
| static let gap: CGFloat = 24 |
| - var paperWidth: CGFloat { min(612, max(160, viewportWidth - 24)) } |
| + var paperWidth: CGFloat { |
| + #if os(macOS) |
| + 612 |
| + #else |
| + min(612, max(160, viewportWidth - 24)) |
| + #endif |
| + } |
| var paperX: CGFloat { max(0, (viewportWidth - paperWidth) / 2) } |
| var sideMargin: CGFloat { paperWidth < 480 ? 20 : 72 } |
| var textX: CGFloat { paperX + sideMargin } |
diff --git a/native/apple/Sources/DocsKit/DocsWorkspace.swift b/native/apple/Sources/DocsKit/DocsWorkspace.swift| index 64a63a1..810d1a7 100644 |
| --- a/native/apple/Sources/DocsKit/DocsWorkspace.swift |
| +++ b/native/apple/Sources/DocsKit/DocsWorkspace.swift |
| @@ -7,14 +7,24 @@ import SwiftUI |
| @State private var creating = false |
| @AppStorage("docs.view.paged") private var paged = true |
| @State private var sharing = false |
| + @State private var exportRequest: String? |
| + @Environment(\.openWindow) private var openWindow |
| @State private var jumping = false |
| @State private var discarding = false |
| @State private var images = DocsImageStore() |
| @State private var column: NavigationSplitViewColumn = .sidebar |
| + private let updateRecents: (([(String, String)]) -> Void)? |
| + private let canSignOut: Bool |
| + @Binding private var createRequested: Bool |
| + private let documentID: String? |
| + private let openDocument: ((String, String) -> Void)? |
| + @State private var zoom = 1.0 |
| + @State private var inspecting = false |
| private let signOut: () -> Void |
| private let accounts: (() -> Void)? |
| private let client: NativeHTTP |
| - public init(client: NativeHTTP, accounts: (() -> Void)? = nil, signOut: @escaping () -> Void) { |
| + public init(client: NativeHTTP, accounts: (() -> Void)? = nil, signOut: @escaping () -> Void, documentID: String? = nil, canSignOut: Bool = true, createRequested: Binding<Bool> = .constant(false), openDocument: ((String, String) -> Void)? = nil, updateRecents: (([(String, String)]) -> Void)? = nil) { |
| + self.updateRecents = updateRecents; self.canSignOut = canSignOut; self._createRequested = createRequested; self.documentID = documentID; self.openDocument = openDocument |
| _model = State(initialValue: DocsModel(client: client)); self.signOut = signOut; self.accounts = accounts; self.client = client |
| } |
| private var library: some View { |
| @@ -31,7 +41,7 @@ import SwiftUI |
| .toolbar { |
| if let accounts { |
| ToolbarItem(placement: .navigation) { |
| - Button("Accounts", systemImage: "chevron.backward", action: accounts).disabled(model.hasUnsavedChanges || model.creatingDocument) |
| + Button(docsMacString("Accounts"), systemImage: "chevron.backward", action: accounts).disabled(model.hasUnsavedChanges || model.creatingDocument) |
| } |
| } |
| @@ -43,9 +53,50 @@ import SwiftUI |
| } |
| } |
| public var body: some View { |
| - NavigationSplitView(preferredCompactColumn: $column) { |
| - library |
| - } detail: { |
| + Group { |
| + #if os(macOS) |
| + if documentID != nil { editor } else { home } |
| + #else |
| + NavigationSplitView(preferredCompactColumn: $column) { library } detail: { editor } |
| + #endif |
| + } |
| + |
| + .onChange(of: model.documents.map { "\($0.sqid)\u{0}\($0.title)" }) { |
| + if query.isEmpty { updateRecents?(model.documents.map { ($0.sqid, $0.title) }) } |
| + } |
| + .onChange(of: createRequested, initial: true) { _, requested in |
| + if requested && documentID == nil { creating = true; createRequested = false } |
| + } |
| + .task { if let documentID { await model.open(documentID) } else { await model.load() } } |
| + .sheet(isPresented: $jumping) { DocsJumpSheet(model: model, client: client) } |
| + .onDisappear { Task { await model.stop() } } |
| + #if os(macOS) |
| + .background { DocsCloseGuard(hasUnsaved: { model.hasUnsavedChanges }, save: { await model.saveAll() }, discard: { await model.discardAll() }).frame(width: 0, height: 0) } |
| + #endif |
| + .onChange(of: model.detail?.sqid) { if model.detail != nil { column = .detail } } |
| + .confirmationDialog(Text("native.discard.title", bundle: DocsResources.bundle), isPresented: $discarding, titleVisibility: .visible) { |
| + Button(role: .destructive) { Task { await model.discardAll(); column = .sidebar } } label: { Text("native.discard.action", bundle: DocsResources.bundle) } |
| + Button(role: .cancel) {} label: { Text("native.close.pending.cancel", bundle: DocsResources.bundle) } |
| + } message: { |
| + Text("native.close.pending.detail", bundle: DocsResources.bundle) |
| + } |
| + .sheet(isPresented: $sharing) { |
| + if let detail = model.detail { DocsSharingView(client: client, sqid: detail.sqid) } |
| + } |
| + .sheet(isPresented: $creating) { |
| + Form { |
| + TextField(String(localized: "docs.list.create.placeholder", bundle: DocsResources.bundle), text: $newTitle) |
| + Button { let title = newTitle; newTitle = ""; creating = false; Task { |
| + #if os(macOS) |
| + if let item = await model.create(title: title, openCreated: false) { openDocument?(item.sqid, item.title) } |
| + #else |
| + await model.create(title: title) |
| + #endif |
| + } } label: { Text("docs.list.create.label", bundle: DocsResources.bundle) }.accessibilityIdentifier("docs.create.submit") |
| + }.padding().presentationDetents([.medium]) |
| + } |
| + } |
| + private var editor: some View { |
| VStack(spacing: 0) { |
| if let key = model.errorKey { |
| HStack { |
| @@ -60,11 +111,20 @@ import SwiftUI |
| if let projection = model.projection { |
| let documentPath = "/d/" + (model.detail?.sqid ?? "") |
| let text = NativeTextProjection.render(projection, images: images.images(forDocumentPath: documentPath, client: client)) |
| + #if os(iOS) |
| EditorToolbar(model: model, content: text) |
| + #endif |
| NativeDocumentTextView(content: text, generation: model.projectionGeneration, didRender: model.didRender, compositionChanged: model.compositionChanged, isEditable: model.detail?.can_write == true && !model.inputPaused, paged: paged, pendingInput: model.hasPendingInput, canRender: model.canRender, currentSelection: model.currentSelection, selection: $model.selection, edit: model.enqueue, action: { action in |
| - if action == "jump" { jumping = true; return true } |
| + if action == "jump" { |
| + #if os(macOS) |
| + openWindow(id: "home") |
| + #else |
| + jumping = true |
| + #endif |
| + return true |
| + } |
| return DocumentCommands(model: model, content: text).perform(action) |
| - }, selectionMoved: model.selectionMovedExplicitly, canAction: { action in model.detail?.can_write == true && !model.hasPendingInput && !model.composing && !model.inputPaused && (action == "undo" ? projection.canUndo : projection.canRedo) }, explicitCommandRevision: model.explicitCommandRevision) |
| + }, selectionMoved: model.selectionMovedExplicitly, canAction: { action in model.detail?.can_write == true && !model.hasPendingInput && !model.composing && !model.inputPaused && (action == "undo" ? projection.canUndo : projection.canRedo) }, explicitCommandRevision: model.explicitCommandRevision, zoom: zoom) |
| .id(model.detail?.sqid) |
| .task(id: [documentPath] + DocsImageStore.sources(in: projection)) { await images.load(sources: DocsImageStore.sources(in: projection), client: client, documentPath: documentPath) } |
| } else { |
| @@ -74,6 +134,7 @@ import SwiftUI |
| #if os(iOS) |
| .navigationBarTitleDisplayMode(.inline) |
| #endif |
| + #if os(iOS) |
| .toolbar { |
| DocsJumpControls(presented: $jumping) |
| if let detail = model.detail { |
| @@ -89,28 +150,71 @@ import SwiftUI |
| } label: { Label { Text("docs.share.title", bundle: DocsResources.bundle) } icon: { Image(systemName: "person.badge.plus") } } |
| } |
| } |
| - } |
| - .task { await model.load() } |
| - .sheet(isPresented: $jumping) { DocsJumpSheet(model: model, client: client) } |
| - .onDisappear { Task { await model.stop() } } |
| - #if os(macOS) |
| - .background { DocsCloseGuard(hasUnsaved: { model.hasUnsavedChanges }, save: { await model.saveAll() }, discard: { await model.discardAll() }).frame(width: 0, height: 0) } |
| - #endif |
| - .onChange(of: model.detail?.sqid) { if model.detail != nil { column = .detail } } |
| - .confirmationDialog(Text("native.discard.title", bundle: DocsResources.bundle), isPresented: $discarding, titleVisibility: .visible) { |
| - Button(role: .destructive) { Task { await model.discardAll(); column = .sidebar } } label: { Text("native.discard.action", bundle: DocsResources.bundle) } |
| - Button(role: .cancel) {} label: { Text("native.close.pending.cancel", bundle: DocsResources.bundle) } |
| - } message: { |
| - Text("native.close.pending.detail", bundle: DocsResources.bundle) |
| - } |
| - .sheet(isPresented: $sharing) { |
| - if let detail = model.detail { DocsSharingView(client: client, sqid: detail.sqid) } |
| - } |
| - .sheet(isPresented: $creating) { |
| - Form { |
| - TextField(String(localized: "docs.list.create.placeholder", bundle: DocsResources.bundle), text: $newTitle) |
| - Button { let title = newTitle; newTitle = ""; creating = false; Task { await model.create(title: title) } } label: { Text("docs.list.create.label", bundle: DocsResources.bundle) }.accessibilityIdentifier("docs.create.submit") |
| - }.padding().presentationDetents([.medium]) |
| + #else |
| + .background { |
| + if let detail = model.detail { |
| + DocumentExportControls(client: client, sqid: detail.sqid, title: detail.title, prepare: { await model.saveAll() }, request: $exportRequest) |
| + .frame(width: 0, height: 0).hidden().accessibilityHidden(true) |
| + } |
| + } |
| + .toolbar { Button(docsMacString("Inspector"), systemImage: "sidebar.right") { inspecting.toggle() } } |
| + .inspector(isPresented: $inspecting) { |
| + Form { |
| + LabeledContent(docsMacString("Document"), value: model.detail?.title ?? "") |
| + Picker(docsMacString("Zoom"), selection: $zoom) { |
| + ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 2.0], id: \.self) { value in |
| + Text(value, format: .percent).tag(value) |
| + } |
| + } |
| + Toggle(docsMacString("Pages"), isOn: $paged) |
| + if let projection = model.projection { |
| + DocsToolsControls(projection: projection, content: NativeTextProjection.render(projection), selection: model.selection) |
| + } |
| + }.formStyle(.grouped).inspectorColumnWidth(min: 240, ideal: 280, max: 340) |
| + } |
| + .focusedSceneValue(\.docsEditor, DocsEditorContext(model: model, client: client, zoom: $zoom, paged: $paged, inspecting: $inspecting, sharing: $sharing, exportRequest: $exportRequest)) |
| + #endif |
| + |
| + } |
| + #if os(macOS) |
| + private var home: some View { |
| + NavigationSplitView { |
| + List { Label(docsMacString("Recent"), systemImage: "clock").listRowBackground(Color.accentColor.opacity(0.12)) } |
| + .navigationSplitViewColumnWidth(min: 160, ideal: 190, max: 240) |
| + } detail: { |
| + ScrollView { |
| + VStack(alignment: .leading, spacing: 24) { |
| + Text(docsMacString("Recent documents")).font(.largeTitle.bold()) |
| + if let error = model.errorKey { |
| + Text(LocalizedStringKey(error), bundle: DocsResources.bundle).foregroundStyle(.red) |
| + Button(docsMacString("Retry")) { Task { await model.load(query: query) } } |
| + } |
| + LazyVGrid(columns: [GridItem(.adaptive(minimum: 180, maximum: 240))], spacing: 24) { |
| + ForEach(model.documents) { document in |
| + Button { openDocument?(document.sqid, document.title) } label: { |
| + VStack(alignment: .leading, spacing: 10) { |
| + DocumentThumbnail(data: document.thumbnail, width: 170, height: 220) |
| + Text(document.title).font(.headline).lineLimit(2) |
| + if document.shared == true { Label(docsMacString("Shared"), systemImage: "person.2").font(.caption) } |
| + }.frame(maxWidth: .infinity, alignment: .leading).padding(12) |
| + }.buttonStyle(.plain) |
| + } |
| + } |
| + if model.documents.isEmpty && model.errorKey == nil { |
| + ContentUnavailableView(docsMacString("No documents"), systemImage: "doc.text", description: Text(docsMacString("Create a document to get started."))) |
| + } |
| + }.padding(28) |
| + } |
| + .navigationTitle("Home") |
| + .searchable(text: $query, prompt: Text("docs.list.search.label", bundle: DocsResources.bundle)) |
| + .onSubmit(of: .search) { Task { await model.load(query: query) } } |
| + .toolbar { |
| + if let accounts { Button(docsMacString("Accounts"), action: accounts) } |
| + Button(docsMacString("New document"), systemImage: "plus") { creating = true }.disabled(!model.canCreate) |
| + Button(docsMacString("Sign out"), action: signOut).disabled(!canSignOut).help(docsMacString("Close this account’s document windows before signing out.")) |
| + } |
| } |
| } |
| + #endif |
| + |
| } |
diff --git a/native/apple/Sources/DocsKit/DocumentExportControls.swift b/native/apple/Sources/DocsKit/DocumentExportControls.swift| index a154f98..7db4b70 100644 |
| --- a/native/apple/Sources/DocsKit/DocumentExportControls.swift |
| +++ b/native/apple/Sources/DocsKit/DocumentExportControls.swift |
| @@ -27,18 +27,27 @@ struct ExportedDocument: Transferable { |
| let sqid: String |
| let title: String |
| let prepare: () async -> Bool |
| + @Binding var request: String? |
| @State private var document: ExportedDocument? |
| @State private var exporting = false |
| @State private var working = false |
| @State private var active = true |
| @State private var type = UTType.pdf |
| @State private var failed = false |
| + init(client: NativeHTTP, sqid: String, title: String, prepare: @escaping () async -> Bool, request: Binding<String?> = .constant(nil)) { |
| + self.client = client; self.sqid = sqid; self.title = title; self.prepare = prepare; self._request = request |
| + } |
| var body: some View { |
| Menu { |
| Button { Task { await download(pdf: true, printDocument: false) } } label: { Text("docs.export.pdf.label", bundle: DocsResources.bundle) } |
| Button { Task { await download(pdf: false, printDocument: false) } } label: { Text("docs.export.markdown.label", bundle: DocsResources.bundle) } |
| Button { Task { await download(pdf: true, printDocument: true) } } label: { Text("docs.editor.menu.file.print", bundle: DocsResources.bundle) } |
| } label: { Image(systemName: "square.and.arrow.up") } |
| + .onChange(of: request) { _, value in |
| + guard let value else { return } |
| + request = nil |
| + Task { await download(pdf: value != "markdown", printDocument: value == "print") } |
| + } |
| .disabled(working || exporting) |
| .accessibilityLabel(Text("native.export", bundle: DocsResources.bundle)) |
| .fileExporter(isPresented: $exporting, item: document, contentTypes: [type], defaultFilename: title, onCompletion: { result in |
diff --git a/native/apple/Sources/DocsKit/DocumentRenderReconciliation.swift b/native/apple/Sources/DocsKit/DocumentRenderReconciliation.swift| new file mode 100644 |
| index 0000000..4c0d9bb |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/DocumentRenderReconciliation.swift |
| @@ -0,0 +1,21 @@ |
| +import Foundation |
| + |
| +/// Reconcile against optimistic native storage, not the last published snapshot. |
| +/// Equal visible text can still carry a new CRDT node mapping after publication. |
| +@MainActor enum DocumentRenderReconciliation { |
| + static func apply(_ content: NSAttributedString, to storage: NSMutableAttributedString) { |
| + guard !storage.isEqual(to: content) else { return } |
| + storage.beginEditing() |
| + defer { storage.endEditing() } |
| + if let (range, replacement) = DocumentComposition.replacement(before: storage.string, after: content.string) { |
| + storage.replaceCharacters(in: range, with: replacement) |
| + } |
| + content.enumerateAttributes(in: NSRange(location: 0, length: content.length)) { attributes, range, _ in |
| + var changed = false |
| + storage.enumerateAttributes(in: range) { current, _, stop in |
| + if !NSDictionary(dictionary: current).isEqual(to: attributes) { changed = true; stop.pointee = true } |
| + } |
| + if changed { storage.setAttributes(attributes, range: range) } |
| + } |
| + } |
| +} |
diff --git a/native/apple/Sources/DocsKit/DocumentThumbnail.swift b/native/apple/Sources/DocsKit/DocumentThumbnail.swift| index 6bca7f0..dd1f6e7 100644 |
| --- a/native/apple/Sources/DocsKit/DocumentThumbnail.swift |
| +++ b/native/apple/Sources/DocsKit/DocumentThumbnail.swift |
| @@ -13,6 +13,8 @@ struct DocumentThumbnailData: Decodable, Sendable { |
| struct DocumentThumbnail: View { |
| let data: DocumentThumbnailData? |
| + var width: CGFloat = 34 |
| + var height: CGFloat = 44 |
| var body: some View { |
| Canvas { context, size in |
| guard let data, data.width > 0, data.height > 0 else { return } |
| @@ -25,7 +27,7 @@ struct DocumentThumbnail: View { |
| } |
| } |
| .foregroundStyle(.secondary) |
| - .frame(width: 34, height: 44) |
| + .frame(width: width, height: height) |
| .background(.background) |
| .clipShape(.rect(cornerRadius: 3)) |
| .overlay { RoundedRectangle(cornerRadius: 3).strokeBorder(.quaternary) } |
diff --git a/native/apple/Sources/DocsKit/NativeDocumentTextView.swift b/native/apple/Sources/DocsKit/NativeDocumentTextView.swift| index 6d4429f..3f012dd 100644 |
| --- a/native/apple/Sources/DocsKit/NativeDocumentTextView.swift |
| +++ b/native/apple/Sources/DocsKit/NativeDocumentTextView.swift |
| @@ -17,10 +17,16 @@ struct NativeDocumentTextView: NSViewRepresentable { |
| var selectionMoved: @MainActor () -> Void = {} |
| var canAction: @MainActor (String) -> Bool = { _ in false } |
| var explicitCommandRevision: UInt64 = 0 |
| + var zoom: Double = 1 |
| func makeCoordinator() -> Coordinator { Coordinator(self) } |
| func makeNSView(context: Context) -> NSScrollView { |
| let scroll = NSScrollView() |
| scroll.hasVerticalScroller = true |
| + scroll.hasHorizontalScroller = true |
| + scroll.minMagnification = 0.5 |
| + scroll.maxMagnification = 3 |
| + scroll.allowsMagnification = false |
| + scroll.magnification = 4.0 / 3.0 |
| let text = DocsTextView(frame: .zero) |
| text.isVerticallyResizable = true |
| text.autoresizingMask = [.width] |
| @@ -46,6 +52,11 @@ struct NativeDocumentTextView: NSViewRepresentable { |
| } |
| func updateNSView(_ scroll: NSScrollView, context: Context) { |
| guard let text = scroll.documentView as? NSTextView else { return } |
| + let magnification = zoom * 4.0 / 3.0 |
| + if abs(scroll.magnification - magnification) > 0.001 { |
| + scroll.setMagnification(magnification, centeredAt: scroll.contentView.bounds.origin) |
| + text.needsLayout = true |
| + } |
| context.coordinator.applyRender(self, to: text) |
| } |
| @MainActor final class Coordinator: NSObject, NSTextViewDelegate { |
| @@ -74,8 +85,9 @@ struct NativeDocumentTextView: NSViewRepresentable { |
| defer { applying = false } |
| rendered = NSAttributedString(attributedString: candidate.content) |
| generation = candidate.generation |
| - text.textStorage?.setAttributedString(candidate.content) |
| - text.setSelectedRange(NSRange(location: min(selected.location, candidate.content.length), length: min(selected.length, max(0, candidate.content.length - selected.location)))) |
| + if let storage = text.textStorage { DocumentRenderReconciliation.apply(candidate.content, to: storage) } |
| + let range = NSRange(location: min(selected.location, candidate.content.length), length: min(selected.length, max(0, candidate.content.length - selected.location))) |
| + if text.selectedRange() != range { text.setSelectedRange(range) } |
| candidate.didRender(candidate.generation) |
| } |
| func ruleEdit(_ command: [String: JSONValue]) -> Bool { |
| @@ -114,6 +126,7 @@ struct NativeDocumentTextView: NSViewRepresentable { |
| } |
| func textViewDidChangeSelection(_ notification: Notification) { |
| guard let text = notification.object as? NSTextView else { return } |
| + guard !applying else { return } |
| let selected = text.selectedRange() |
| if selected.length == 0 && selected.location == text.string.utf16.count && text.string.hasSuffix("\n") { |
| text.setSelectedRange(NSRange(location: max(0, selected.location - 1), length: 0)); return |
| @@ -147,6 +160,7 @@ struct NativeDocumentTextView: UIViewRepresentable { |
| var selectionMoved: @MainActor () -> Void = {} |
| var canAction: @MainActor (String) -> Bool = { _ in false } |
| var explicitCommandRevision: UInt64 = 0 |
| + var zoom: Double = 1 |
| func makeCoordinator() -> Coordinator { Coordinator(self) } |
| func makeUIView(context: Context) -> UITextView { |
| let text = DocsTextView() |
diff --git a/native/apple/Sources/DocsKit/NativeTextProjection.swift b/native/apple/Sources/DocsKit/NativeTextProjection.swift| index a958aca..98a5878 100644 |
| --- a/native/apple/Sources/DocsKit/NativeTextProjection.swift |
| +++ b/native/apple/Sources/DocsKit/NativeTextProjection.swift |
| @@ -34,7 +34,7 @@ extension NSAttributedString.Key { |
| } |
| let start = output.length |
| let style = NSMutableParagraphStyle() |
| - style.paragraphSpacing = 8 |
| + style.paragraphSpacing = 0 |
| style.textLists = lists |
| let indent = max(0, min(8, Int(node.attrs?["indent"] ?? "0") ?? 0)) |
| style.headIndent = CGFloat(depth * 24 + indent * 36) |
| @@ -44,7 +44,20 @@ extension NSAttributedString.Key { |
| #if os(macOS) |
| style.headerLevel = heading |
| #endif |
| - let base = heading > 0 ? max(14, 28 - CGFloat(heading * 3)) : 12 |
| + let base: CGFloat = heading == 1 ? 19.2 : heading == 2 ? 15.6 : heading == 3 ? 13.2 : 12 |
| + let leading: CGFloat = heading > 0 ? 1.3 : 1.4 |
| + let baseFont = font(marks: [:], size: base, heading: heading > 0) |
| + #if os(macOS) |
| + let naturalHeight = NSLayoutManager().defaultLineHeight(for: baseFont) |
| + #else |
| + let naturalHeight = baseFont.lineHeight |
| + #endif |
| + style.lineHeightMultiple = base * leading / naturalHeight |
| + style.minimumLineHeight = base * leading |
| + if heading > 0 { |
| + style.paragraphSpacingBefore = base * 1.5 |
| + style.paragraphSpacing = base * 0.5 |
| + } |
| var baseAttributes: [NSAttributedString.Key: Any] = [.paragraphStyle: style, .font: font(marks: node.type == "code_block" ? ["code": .object([:])] : [:], size: base, heading: heading > 0), .docsBlock: node.id, .docsBlockType: node.type, .docsHeading: heading] |
| baseAttributes[.docsBlockAttributes] = try? JSONEncoder().encode(node.attrs ?? [:]) |
| baseAttributes[.docsClipboardContainers] = try? JSONEncoder().encode(containers) |
diff --git a/native/apple/Sources/DocsKit/Resources/ar.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/ar.lproj/Mac.strings| new file mode 100644 |
| index 0000000..b38b2b3 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/Resources/ar.lproj/Mac.strings |
| @@ -0,0 +1,49 @@ |
| +"Accounts" = "الحسابات"; |
| +"Actual size" = "الحجم الفعلي"; |
| +"Alignment" = "المحاذاة"; |
| +"Block quote" = "كتلة اقتباس"; |
| +"Bold" = "عريض"; |
| +"Bullet list" = "قائمة نقطية"; |
| +"Cell contents" = "محتوى الخلية"; |
| +"Clear formatting" = "مسح التنسيق"; |
| +"Close this account’s document windows before signing out." = "أغلق نوافذ مستندات هذا الحساب قبل تسجيل الخروج."; |
| +"Create a document to get started." = "ستظهر المستندات التي تنشئها هنا، بدءًا بالأحدث."; |
| +"Docs" = "Docs"; |
| +"Document" = "المستند"; |
| +"Document inspector" = "خصائص المستند"; |
| +"Double-click to fit column to contents" = "انقر مرتين لملاءمة العمود للمحتوى"; |
| +"Edit %@" = "تحرير %@"; |
| +"Export" = "تصدير"; |
| +"Fit column to contents" = "ملاءمة العمود للمحتوى"; |
| +"Font" = "الخط"; |
| +"Format" = "تنسيق"; |
| +"Heading %d" = "العنوان %d"; |
| +"Home" = "الرئيسية"; |
| +"Indent" = "زيادة المسافة البادئة"; |
| +"Inline code" = "شيفرة"; |
| +"Inspector" = "خصائص المستند"; |
| +"Italic" = "مائل"; |
| +"Markdown…" = "Markdown"; |
| +"New document" = "مستند جديد"; |
| +"No documents" = "لا توجد مستندات بعد"; |
| +"No recent documents" = "لا توجد مستندات بعد"; |
| +"Normal" = "نص أساسي"; |
| +"Numbered list" = "قائمة مرقّمة"; |
| +"Open a document from Home" = "افتح مستندًا من الرئيسية"; |
| +"Outdent" = "تقليل المسافة البادئة"; |
| +"PDF…" = "PDF"; |
| +"Pages" = "صفحات"; |
| +"Paragraph style" = "نمط الفقرة"; |
| +"Print…" = "طباعة"; |
| +"Recent" = "الأخيرة"; |
| +"Recent documents" = "المستندات الأخيرة"; |
| +"Recent spreadsheets" = "جداول البيانات الأخيرة"; |
| +"Retry" = "إعادة المحاولة"; |
| +"Save" = "حفظ"; |
| +"Select column %@" = "تحديد العمود %@"; |
| +"Select row %d" = "تحديد الصف %d"; |
| +"Shared" = "تمت مشاركتها معك"; |
| +"Share…" = "مشاركة"; |
| +"Sign out" = "تسجيل الخروج"; |
| +"Size" = "الحجم"; |
| +"Zoom" = "التكبير"; |
diff --git a/native/apple/Sources/DocsKit/Resources/bn.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/bn.lproj/Mac.strings| new file mode 100644 |
| index 0000000..ccde98e |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/Resources/bn.lproj/Mac.strings |
| @@ -0,0 +1,49 @@ |
| +"Accounts" = "অ্যাকাউন্ট"; |
| +"Actual size" = "প্রকৃত আকার"; |
| +"Alignment" = "প্রান্তিককরণ"; |
| +"Block quote" = "উদ্ধৃতির ব্লক"; |
| +"Bold" = "গাঢ়"; |
| +"Bullet list" = "বুলেটযুক্ত তালিকা"; |
| +"Cell contents" = "ঘরের বিষয়বস্তু"; |
| +"Clear formatting" = "বিন্যাস মুছুন"; |
| +"Close this account’s document windows before signing out." = "সাইন আউট করার আগে এই অ্যাকাউন্টের নথির উইন্ডোগুলি বন্ধ করুন।"; |
| +"Create a document to get started." = "আপনার তৈরি নথি এখানে দেখা যাবে, নতুনগুলো আগে।"; |
| +"Docs" = "Docs"; |
| +"Document" = "নথি"; |
| +"Document inspector" = "নথি পরিদর্শক"; |
| +"Double-click to fit column to contents" = "বিষয়বস্তু অনুযায়ী কলাম মাপতে দুবার ক্লিক করুন"; |
| +"Edit %@" = "%@ সম্পাদনা করুন"; |
| +"Export" = "রপ্তানি"; |
| +"Fit column to contents" = "বিষয়বস্তু অনুযায়ী কলাম মাপুন"; |
| +"Font" = "ফন্ট"; |
| +"Format" = "বিন্যাস"; |
| +"Heading %d" = "শিরোনাম %d"; |
| +"Home" = "হোম"; |
| +"Indent" = "ইন্ডেন্ট বাড়ান"; |
| +"Inline code" = "কোড"; |
| +"Inspector" = "নথি পরিদর্শক"; |
| +"Italic" = "তির্যক"; |
| +"Markdown…" = "Markdown"; |
| +"New document" = "নতুন নথি"; |
| +"No documents" = "এখনও কোনো নথি নেই"; |
| +"No recent documents" = "এখনও কোনো নথি নেই"; |
| +"Normal" = "মূল পাঠ্য"; |
| +"Numbered list" = "সংখ্যাযুক্ত তালিকা"; |
| +"Open a document from Home" = "হোম থেকে একটি নথি খুলুন"; |
| +"Outdent" = "ইন্ডেন্ট কমান"; |
| +"PDF…" = "PDF"; |
| +"Pages" = "পৃষ্ঠাভিত্তিক"; |
| +"Paragraph style" = "অনুচ্ছেদের শৈলী"; |
| +"Print…" = "প্রিন্ট করুন"; |
| +"Recent" = "সাম্প্রতিক"; |
| +"Recent documents" = "সাম্প্রতিক নথি"; |
| +"Recent spreadsheets" = "সাম্প্রতিক স্প্রেডশিট"; |
| +"Retry" = "আবার চেষ্টা করুন"; |
| +"Save" = "সংরক্ষণ করুন"; |
| +"Select column %@" = "কলাম %@ নির্বাচন করুন"; |
| +"Select row %d" = "সারি %d নির্বাচন করুন"; |
| +"Shared" = "আপনার সঙ্গে শেয়ার করা"; |
| +"Share…" = "শেয়ার করুন"; |
| +"Sign out" = "সাইন আউট করুন"; |
| +"Size" = "আকার"; |
| +"Zoom" = "জুম"; |
diff --git a/native/apple/Sources/DocsKit/Resources/en.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/en.lproj/Mac.strings| new file mode 100644 |
| index 0000000..ce26f16 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/Resources/en.lproj/Mac.strings |
| @@ -0,0 +1,49 @@ |
| +"Accounts" = "Accounts"; |
| +"Actual size" = "Actual size"; |
| +"Alignment" = "Alignment"; |
| +"Block quote" = "Block quote"; |
| +"Bold" = "Bold"; |
| +"Bullet list" = "Bulleted list"; |
| +"Cell contents" = "Cell contents"; |
| +"Clear formatting" = "Clear formatting"; |
| +"Close this account’s document windows before signing out." = "Close this account’s document windows before signing out."; |
| +"Create a document to get started." = "Documents you create will show up here, newest first."; |
| +"Docs" = "Docs"; |
| +"Document" = "Document"; |
| +"Document inspector" = "Document inspector"; |
| +"Double-click to fit column to contents" = "Double-click to fit column to contents"; |
| +"Edit %@" = "Edit %@"; |
| +"Export" = "Export"; |
| +"Fit column to contents" = "Fit column to contents"; |
| +"Font" = "Font"; |
| +"Format" = "Format"; |
| +"Heading %d" = "Heading %d"; |
| +"Home" = "Home"; |
| +"Indent" = "Increase indent"; |
| +"Inline code" = "Code"; |
| +"Inspector" = "Document inspector"; |
| +"Italic" = "Italic"; |
| +"Markdown…" = "Markdown"; |
| +"New document" = "New document"; |
| +"No documents" = "No documents yet"; |
| +"No recent documents" = "No documents yet"; |
| +"Normal" = "Body text"; |
| +"Numbered list" = "Numbered list"; |
| +"Open a document from Home" = "Open a document from Home"; |
| +"Outdent" = "Decrease indent"; |
| +"PDF…" = "PDF"; |
| +"Pages" = "Paged"; |
| +"Paragraph style" = "Paragraph style"; |
| +"Print…" = "Print"; |
| +"Recent" = "Recent"; |
| +"Recent documents" = "Recent documents"; |
| +"Recent spreadsheets" = "Recent spreadsheets"; |
| +"Retry" = "Try again"; |
| +"Save" = "Save"; |
| +"Select column %@" = "Select column %@"; |
| +"Select row %d" = "Select row %d"; |
| +"Shared" = "Shared with you"; |
| +"Share…" = "Share"; |
| +"Sign out" = "Sign out"; |
| +"Size" = "Size"; |
| +"Zoom" = "Zoom"; |
diff --git a/native/apple/Sources/DocsKit/Resources/es.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/es.lproj/Mac.strings| new file mode 100644 |
| index 0000000..a923884 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/Resources/es.lproj/Mac.strings |
| @@ -0,0 +1,49 @@ |
| +"Accounts" = "Cuentas"; |
| +"Actual size" = "Tamaño real"; |
| +"Alignment" = "Alineación"; |
| +"Block quote" = "Cita en bloque"; |
| +"Bold" = "Negrita"; |
| +"Bullet list" = "Lista con viñetas"; |
| +"Cell contents" = "Contenido de la celda"; |
| +"Clear formatting" = "Borrar formato"; |
| +"Close this account’s document windows before signing out." = "Cierra las ventanas de documentos de esta cuenta antes de cerrar sesión."; |
| +"Create a document to get started." = "Los documentos que crees aparecerán aquí, empezando por los más recientes."; |
| +"Docs" = "Docs"; |
| +"Document" = "Documento"; |
| +"Document inspector" = "Inspector del documento"; |
| +"Double-click to fit column to contents" = "Haz doble clic para ajustar la columna al contenido"; |
| +"Edit %@" = "Editar %@"; |
| +"Export" = "Exportar"; |
| +"Fit column to contents" = "Ajustar columna al contenido"; |
| +"Font" = "Fuente"; |
| +"Format" = "Formato"; |
| +"Heading %d" = "Título %d"; |
| +"Home" = "Inicio"; |
| +"Indent" = "Aumentar sangría"; |
| +"Inline code" = "Código"; |
| +"Inspector" = "Inspector del documento"; |
| +"Italic" = "Cursiva"; |
| +"Markdown…" = "Markdown"; |
| +"New document" = "Nuevo documento"; |
| +"No documents" = "Aún no hay documentos"; |
| +"No recent documents" = "Aún no hay documentos"; |
| +"Normal" = "Texto del cuerpo"; |
| +"Numbered list" = "Lista numerada"; |
| +"Open a document from Home" = "Abre un documento desde Inicio"; |
| +"Outdent" = "Reducir sangría"; |
| +"PDF…" = "PDF"; |
| +"Pages" = "Por páginas"; |
| +"Paragraph style" = "Estilo de párrafo"; |
| +"Print…" = "Imprimir"; |
| +"Recent" = "Recientes"; |
| +"Recent documents" = "Documentos recientes"; |
| +"Recent spreadsheets" = "Hojas de cálculo recientes"; |
| +"Retry" = "Reintentar"; |
| +"Save" = "Guardar"; |
| +"Select column %@" = "Seleccionar columna %@"; |
| +"Select row %d" = "Seleccionar fila %d"; |
| +"Shared" = "Compartidos contigo"; |
| +"Share…" = "Compartir"; |
| +"Sign out" = "Cerrar sesión"; |
| +"Size" = "Tamaño"; |
| +"Zoom" = "Zoom"; |
diff --git a/native/apple/Sources/DocsKit/Resources/ga.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/ga.lproj/Mac.strings| new file mode 100644 |
| index 0000000..0ccaab5 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/Resources/ga.lproj/Mac.strings |
| @@ -0,0 +1,49 @@ |
| +"Accounts" = "Cuntais"; |
| +"Actual size" = "Fíormhéid"; |
| +"Alignment" = "Ailíniú"; |
| +"Block quote" = "Athfhriotal i mbloc"; |
| +"Bold" = "Trom"; |
| +"Bullet list" = "Liosta le hurchair"; |
| +"Cell contents" = "Ábhar na cille"; |
| +"Clear formatting" = "Glan an fhormáidiú"; |
| +"Close this account’s document windows before signing out." = "Dún fuinneoga cáipéisí an chuntais seo sula síníonn tú amach."; |
| +"Create a document to get started." = "Beidh na doiciméid a chruthaíonn tú anseo, na cinn is nua ar dtús."; |
| +"Docs" = "Docs"; |
| +"Document" = "Cáipéis"; |
| +"Document inspector" = "Cigire cáipéise"; |
| +"Double-click to fit column to contents" = "Déchliceáil chun an colún a oiriúnú don ábhar"; |
| +"Edit %@" = "Cuir %@ in eagar"; |
| +"Export" = "Easpórtáil"; |
| +"Fit column to contents" = "Oiriúnaigh an colún don ábhar"; |
| +"Font" = "Cló"; |
| +"Format" = "Formáid"; |
| +"Heading %d" = "Ceannteideal %d"; |
| +"Home" = "Baile"; |
| +"Indent" = "Méadaigh an eangú"; |
| +"Inline code" = "Cód"; |
| +"Inspector" = "Cigire cáipéise"; |
| +"Italic" = "Iodálach"; |
| +"Markdown…" = "Markdown"; |
| +"New document" = "Doiciméad nua"; |
| +"No documents" = "Níl aon doiciméid ann fós"; |
| +"No recent documents" = "Níl aon doiciméid ann fós"; |
| +"Normal" = "Téacs coirp"; |
| +"Numbered list" = "Liosta uimhrithe"; |
| +"Open a document from Home" = "Oscail cáipéis ón mBaile"; |
| +"Outdent" = "Laghdaigh an eangú"; |
| +"PDF…" = "PDF"; |
| +"Pages" = "Leathanaigh"; |
| +"Paragraph style" = "Stíl ailt"; |
| +"Print…" = "Priontáil"; |
| +"Recent" = "Le déanaí"; |
| +"Recent documents" = "Cáipéisí le déanaí"; |
| +"Recent spreadsheets" = "Scarbhileoga le déanaí"; |
| +"Retry" = "Bain triail eile as"; |
| +"Save" = "Sábháil"; |
| +"Select column %@" = "Roghnaigh colún %@"; |
| +"Select row %d" = "Roghnaigh ró %d"; |
| +"Shared" = "Comhroinnte leat"; |
| +"Share…" = "Comhroinn"; |
| +"Sign out" = "Sínigh amach"; |
| +"Size" = "Méid"; |
| +"Zoom" = "Súmáil"; |
diff --git a/native/apple/Sources/DocsKit/Resources/hi.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/hi.lproj/Mac.strings| new file mode 100644 |
| index 0000000..0f82468 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/Resources/hi.lproj/Mac.strings |
| @@ -0,0 +1,49 @@ |
| +"Accounts" = "खाते"; |
| +"Actual size" = "वास्तविक आकार"; |
| +"Alignment" = "संरेखण"; |
| +"Block quote" = "ब्लॉक उद्धरण"; |
| +"Bold" = "बोल्ड"; |
| +"Bullet list" = "बुलेट वाली सूची"; |
| +"Cell contents" = "सेल की सामग्री"; |
| +"Clear formatting" = "फ़ॉर्मैटिंग हटाएँ"; |
| +"Close this account’s document windows before signing out." = "साइन आउट करने से पहले इस खाते की दस्तावेज़ विंडो बंद करें।"; |
| +"Create a document to get started." = "आपके बनाए दस्तावेज़ यहाँ दिखेंगे, सबसे नए पहले।"; |
| +"Docs" = "Docs"; |
| +"Document" = "दस्तावेज़"; |
| +"Document inspector" = "दस्तावेज़ निरीक्षक"; |
| +"Double-click to fit column to contents" = "सामग्री के अनुसार आकार बदलने के लिए डबल-क्लिक करें"; |
| +"Edit %@" = "%@ संपादित करें"; |
| +"Export" = "निर्यात"; |
| +"Fit column to contents" = "सामग्री के अनुसार स्तंभ का आकार बदलें"; |
| +"Font" = "फ़ॉन्ट"; |
| +"Format" = "फ़ॉर्मैट"; |
| +"Heading %d" = "शीर्षक %d"; |
| +"Home" = "होम"; |
| +"Indent" = "इंडेंट बढ़ाएँ"; |
| +"Inline code" = "कोड"; |
| +"Inspector" = "दस्तावेज़ निरीक्षक"; |
| +"Italic" = "इटैलिक"; |
| +"Markdown…" = "Markdown"; |
| +"New document" = "नया दस्तावेज़"; |
| +"No documents" = "अभी कोई दस्तावेज़ नहीं"; |
| +"No recent documents" = "अभी कोई दस्तावेज़ नहीं"; |
| +"Normal" = "मुख्य पाठ"; |
| +"Numbered list" = "क्रमांकित सूची"; |
| +"Open a document from Home" = "होम से दस्तावेज़ खोलें"; |
| +"Outdent" = "इंडेंट घटाएँ"; |
| +"PDF…" = "PDF"; |
| +"Pages" = "पृष्ठों में"; |
| +"Paragraph style" = "अनुच्छेद शैली"; |
| +"Print…" = "प्रिंट करें"; |
| +"Recent" = "हाल के"; |
| +"Recent documents" = "हाल के दस्तावेज़"; |
| +"Recent spreadsheets" = "हाल की स्प्रेडशीट"; |
| +"Retry" = "फिर कोशिश करें"; |
| +"Save" = "सहेजें"; |
| +"Select column %@" = "स्तंभ %@ चुनें"; |
| +"Select row %d" = "पंक्ति %d चुनें"; |
| +"Shared" = "आपके साथ शेयर किए गए"; |
| +"Share…" = "शेयर करें"; |
| +"Sign out" = "साइन आउट करें"; |
| +"Size" = "आकार"; |
| +"Zoom" = "ज़ूम"; |
diff --git a/native/apple/Sources/DocsKit/Resources/ja.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/ja.lproj/Mac.strings| new file mode 100644 |
| index 0000000..b074b76 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/Resources/ja.lproj/Mac.strings |
| @@ -0,0 +1,49 @@ |
| +"Accounts" = "アカウント"; |
| +"Actual size" = "実際のサイズ"; |
| +"Alignment" = "配置"; |
| +"Block quote" = "引用ブロック"; |
| +"Bold" = "太字"; |
| +"Bullet list" = "箇条書き"; |
| +"Cell contents" = "セルの内容"; |
| +"Clear formatting" = "書式をクリア"; |
| +"Close this account’s document windows before signing out." = "サインアウトする前に、このアカウントの書類ウインドウを閉じてください。"; |
| +"Create a document to get started." = "作成したドキュメントが新しい順にここに表示されます。"; |
| +"Docs" = "Docs"; |
| +"Document" = "書類"; |
| +"Document inspector" = "書類インスペクタ"; |
| +"Double-click to fit column to contents" = "ダブルクリックで列幅を内容に合わせる"; |
| +"Edit %@" = "%@ を編集"; |
| +"Export" = "書き出す"; |
| +"Fit column to contents" = "列幅を内容に合わせる"; |
| +"Font" = "フォント"; |
| +"Format" = "書式"; |
| +"Heading %d" = "見出し %d"; |
| +"Home" = "ホーム"; |
| +"Indent" = "インデントを増やす"; |
| +"Inline code" = "コード"; |
| +"Inspector" = "書類インスペクタ"; |
| +"Italic" = "斜体"; |
| +"Markdown…" = "Markdown"; |
| +"New document" = "新規ドキュメント"; |
| +"No documents" = "ドキュメントはまだありません"; |
| +"No recent documents" = "ドキュメントはまだありません"; |
| +"Normal" = "本文"; |
| +"Numbered list" = "番号付きリスト"; |
| +"Open a document from Home" = "ホームから書類を開く"; |
| +"Outdent" = "インデントを減らす"; |
| +"PDF…" = "PDF"; |
| +"Pages" = "ページ区切り"; |
| +"Paragraph style" = "段落スタイル"; |
| +"Print…" = "プリント"; |
| +"Recent" = "最近使った項目"; |
| +"Recent documents" = "最近の書類"; |
| +"Recent spreadsheets" = "最近のスプレッドシート"; |
| +"Retry" = "再試行"; |
| +"Save" = "保存"; |
| +"Select column %@" = "列 %@ を選択"; |
| +"Select row %d" = "行 %d を選択"; |
| +"Shared" = "あなたと共有"; |
| +"Share…" = "共有"; |
| +"Sign out" = "サインアウト"; |
| +"Size" = "サイズ"; |
| +"Zoom" = "ズーム"; |
diff --git a/native/apple/Sources/DocsKit/Resources/pt.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/pt.lproj/Mac.strings| new file mode 100644 |
| index 0000000..8062b59 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/Resources/pt.lproj/Mac.strings |
| @@ -0,0 +1,49 @@ |
| +"Accounts" = "Contas"; |
| +"Actual size" = "Tamanho real"; |
| +"Alignment" = "Alinhamento"; |
| +"Block quote" = "Citação em bloco"; |
| +"Bold" = "Negrito"; |
| +"Bullet list" = "Lista com marcas"; |
| +"Cell contents" = "Conteúdo da célula"; |
| +"Clear formatting" = "Limpar formatação"; |
| +"Close this account’s document windows before signing out." = "Feche as janelas de documentos desta conta antes de terminar a sessão."; |
| +"Create a document to get started." = "Os documentos que criar aparecem aqui, começando pelos mais recentes."; |
| +"Docs" = "Docs"; |
| +"Document" = "Documento"; |
| +"Document inspector" = "Inspetor do documento"; |
| +"Double-click to fit column to contents" = "Clique duas vezes para ajustar a coluna ao conteúdo"; |
| +"Edit %@" = "Editar %@"; |
| +"Export" = "Exportar"; |
| +"Fit column to contents" = "Ajustar coluna ao conteúdo"; |
| +"Font" = "Tipo de letra"; |
| +"Format" = "Formato"; |
| +"Heading %d" = "Título %d"; |
| +"Home" = "Início"; |
| +"Indent" = "Aumentar avanço"; |
| +"Inline code" = "Código"; |
| +"Inspector" = "Inspetor do documento"; |
| +"Italic" = "Itálico"; |
| +"Markdown…" = "Markdown"; |
| +"New document" = "Novo documento"; |
| +"No documents" = "Ainda não há documentos"; |
| +"No recent documents" = "Ainda não há documentos"; |
| +"Normal" = "Corpo do texto"; |
| +"Numbered list" = "Lista numerada"; |
| +"Open a document from Home" = "Abra um documento no Início"; |
| +"Outdent" = "Diminuir avanço"; |
| +"PDF…" = "PDF"; |
| +"Pages" = "Paginado"; |
| +"Paragraph style" = "Estilo de parágrafo"; |
| +"Print…" = "Imprimir"; |
| +"Recent" = "Recentes"; |
| +"Recent documents" = "Documentos recentes"; |
| +"Recent spreadsheets" = "Folhas de cálculo recentes"; |
| +"Retry" = "Tentar novamente"; |
| +"Save" = "Guardar"; |
| +"Select column %@" = "Selecionar coluna %@"; |
| +"Select row %d" = "Selecionar linha %d"; |
| +"Shared" = "Partilhados consigo"; |
| +"Share…" = "Partilhar"; |
| +"Sign out" = "Terminar sessão"; |
| +"Size" = "Tamanho"; |
| +"Zoom" = "Zoom"; |
diff --git a/native/apple/Sources/DocsKit/Resources/ru.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/ru.lproj/Mac.strings| new file mode 100644 |
| index 0000000..9f07e95 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/Resources/ru.lproj/Mac.strings |
| @@ -0,0 +1,49 @@ |
| +"Accounts" = "Учётные записи"; |
| +"Actual size" = "Фактический размер"; |
| +"Alignment" = "Выравнивание"; |
| +"Block quote" = "Блок цитаты"; |
| +"Bold" = "Полужирный"; |
| +"Bullet list" = "Маркированный список"; |
| +"Cell contents" = "Содержимое ячейки"; |
| +"Clear formatting" = "Очистить форматирование"; |
| +"Close this account’s document windows before signing out." = "Перед выходом закройте окна документов этой учётной записи."; |
| +"Create a document to get started." = "Созданные вами документы появятся здесь, начиная с самых новых."; |
| +"Docs" = "Docs"; |
| +"Document" = "Документ"; |
| +"Document inspector" = "Инспектор документа"; |
| +"Double-click to fit column to contents" = "Дважды щёлкните для подгонки ширины столбца"; |
| +"Edit %@" = "Изменить %@"; |
| +"Export" = "Экспорт"; |
| +"Fit column to contents" = "Подогнать ширину столбца"; |
| +"Font" = "Шрифт"; |
| +"Format" = "Формат"; |
| +"Heading %d" = "Заголовок %d"; |
| +"Home" = "Главная"; |
| +"Indent" = "Увеличить отступ"; |
| +"Inline code" = "Код"; |
| +"Inspector" = "Инспектор документа"; |
| +"Italic" = "Курсив"; |
| +"Markdown…" = "Markdown"; |
| +"New document" = "Новый документ"; |
| +"No documents" = "Документов пока нет"; |
| +"No recent documents" = "Документов пока нет"; |
| +"Normal" = "Основной текст"; |
| +"Numbered list" = "Нумерованный список"; |
| +"Open a document from Home" = "Откройте документ на главной"; |
| +"Outdent" = "Уменьшить отступ"; |
| +"PDF…" = "PDF"; |
| +"Pages" = "Постраничный"; |
| +"Paragraph style" = "Стиль абзаца"; |
| +"Print…" = "Печать"; |
| +"Recent" = "Недавние"; |
| +"Recent documents" = "Недавние документы"; |
| +"Recent spreadsheets" = "Недавние таблицы"; |
| +"Retry" = "Повторить попытку"; |
| +"Save" = "Сохранить"; |
| +"Select column %@" = "Выбрать столбец %@"; |
| +"Select row %d" = "Выбрать строку %d"; |
| +"Shared" = "Доступные вам"; |
| +"Share…" = "Поделиться"; |
| +"Sign out" = "Выйти"; |
| +"Size" = "Размер"; |
| +"Zoom" = "Масштаб"; |
diff --git a/native/apple/Sources/DocsKit/Resources/vi.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/vi.lproj/Mac.strings| new file mode 100644 |
| index 0000000..68b9667 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/Resources/vi.lproj/Mac.strings |
| @@ -0,0 +1,49 @@ |
| +"Accounts" = "Tài khoản"; |
| +"Actual size" = "Kích thước thực"; |
| +"Alignment" = "Căn chỉnh"; |
| +"Block quote" = "Khối trích dẫn"; |
| +"Bold" = "Đậm"; |
| +"Bullet list" = "Danh sách dấu đầu dòng"; |
| +"Cell contents" = "Nội dung ô"; |
| +"Clear formatting" = "Xóa định dạng"; |
| +"Close this account’s document windows before signing out." = "Đóng các cửa sổ tài liệu của tài khoản này trước khi đăng xuất."; |
| +"Create a document to get started." = "Tài liệu bạn tạo sẽ xuất hiện ở đây, mới nhất trước."; |
| +"Docs" = "Docs"; |
| +"Document" = "Tài liệu"; |
| +"Document inspector" = "Thuộc tính tài liệu"; |
| +"Double-click to fit column to contents" = "Nhấp đúp để khớp cột với nội dung"; |
| +"Edit %@" = "Sửa %@"; |
| +"Export" = "Xuất"; |
| +"Fit column to contents" = "Khớp cột với nội dung"; |
| +"Font" = "Phông chữ"; |
| +"Format" = "Định dạng"; |
| +"Heading %d" = "Tiêu đề %d"; |
| +"Home" = "Trang chủ"; |
| +"Indent" = "Tăng thụt lề"; |
| +"Inline code" = "Mã"; |
| +"Inspector" = "Thuộc tính tài liệu"; |
| +"Italic" = "Nghiêng"; |
| +"Markdown…" = "Markdown"; |
| +"New document" = "Tài liệu mới"; |
| +"No documents" = "Chưa có tài liệu"; |
| +"No recent documents" = "Chưa có tài liệu"; |
| +"Normal" = "Văn bản nội dung"; |
| +"Numbered list" = "Danh sách đánh số"; |
| +"Open a document from Home" = "Mở tài liệu từ Trang chủ"; |
| +"Outdent" = "Giảm thụt lề"; |
| +"PDF…" = "PDF"; |
| +"Pages" = "Theo trang"; |
| +"Paragraph style" = "Kiểu đoạn văn"; |
| +"Print…" = "In"; |
| +"Recent" = "Gần đây"; |
| +"Recent documents" = "Tài liệu gần đây"; |
| +"Recent spreadsheets" = "Bảng tính gần đây"; |
| +"Retry" = "Thử lại"; |
| +"Save" = "Lưu"; |
| +"Select column %@" = "Chọn cột %@"; |
| +"Select row %d" = "Chọn hàng %d"; |
| +"Shared" = "Được chia sẻ với bạn"; |
| +"Share…" = "Chia sẻ"; |
| +"Sign out" = "Đăng xuất"; |
| +"Size" = "Kích cỡ"; |
| +"Zoom" = "Thu phóng"; |
diff --git a/native/apple/Sources/DocsKit/Resources/yue.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/yue.lproj/Mac.strings| new file mode 100644 |
| index 0000000..1779f5b |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/Resources/yue.lproj/Mac.strings |
| @@ -0,0 +1,49 @@ |
| +"Accounts" = "帳戶"; |
| +"Actual size" = "實際大小"; |
| +"Alignment" = "對齊"; |
| +"Block quote" = "引文區塊"; |
| +"Bold" = "粗體"; |
| +"Bullet list" = "項目符號清單"; |
| +"Cell contents" = "儲存格內容"; |
| +"Clear formatting" = "清除格式"; |
| +"Close this account’s document windows before signing out." = "登出前請先關閉此帳戶嘅文件視窗。"; |
| +"Create a document to get started." = "你建立嘅文件會喺呢度顯示,最新嘅排最前。"; |
| +"Docs" = "Docs"; |
| +"Document" = "文件"; |
| +"Document inspector" = "文件檢查器"; |
| +"Double-click to fit column to contents" = "按兩下以按內容調整欄寬"; |
| +"Edit %@" = "編輯 %@"; |
| +"Export" = "輸出"; |
| +"Fit column to contents" = "按內容調整欄寬"; |
| +"Font" = "字體"; |
| +"Format" = "格式"; |
| +"Heading %d" = "標題 %d"; |
| +"Home" = "主頁"; |
| +"Indent" = "增加縮排"; |
| +"Inline code" = "程式碼"; |
| +"Inspector" = "文件檢查器"; |
| +"Italic" = "斜體"; |
| +"Markdown…" = "Markdown"; |
| +"New document" = "新增文件"; |
| +"No documents" = "仲未有文件"; |
| +"No recent documents" = "仲未有文件"; |
| +"Normal" = "內文"; |
| +"Numbered list" = "編號清單"; |
| +"Open a document from Home" = "由主頁開啟文件"; |
| +"Outdent" = "減少縮排"; |
| +"PDF…" = "PDF"; |
| +"Pages" = "分頁"; |
| +"Paragraph style" = "段落樣式"; |
| +"Print…" = "列印"; |
| +"Recent" = "最近項目"; |
| +"Recent documents" = "最近文件"; |
| +"Recent spreadsheets" = "最近試算表"; |
| +"Retry" = "再試一次"; |
| +"Save" = "儲存"; |
| +"Select column %@" = "選取 %@ 欄"; |
| +"Select row %d" = "選取第 %d 行"; |
| +"Shared" = "同你分享嘅文件"; |
| +"Share…" = "分享"; |
| +"Sign out" = "登出"; |
| +"Size" = "大小"; |
| +"Zoom" = "縮放"; |
diff --git a/native/apple/Sources/DocsKit/Resources/zh-Hans.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/zh-Hans.lproj/Mac.strings| new file mode 100644 |
| index 0000000..8cb3867 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/Resources/zh-Hans.lproj/Mac.strings |
| @@ -0,0 +1,49 @@ |
| +"Accounts" = "账户"; |
| +"Actual size" = "实际大小"; |
| +"Alignment" = "对齐"; |
| +"Block quote" = "块引用"; |
| +"Bold" = "粗体"; |
| +"Bullet list" = "项目符号列表"; |
| +"Cell contents" = "单元格内容"; |
| +"Clear formatting" = "清除格式"; |
| +"Close this account’s document windows before signing out." = "退出登录前请关闭此账户的文档窗口。"; |
| +"Create a document to get started." = "您创建的文档会显示在这里,最新的排在最前面。"; |
| +"Docs" = "Docs"; |
| +"Document" = "文档"; |
| +"Document inspector" = "文档检查器"; |
| +"Double-click to fit column to contents" = "双击以根据内容调整列宽"; |
| +"Edit %@" = "编辑 %@"; |
| +"Export" = "导出"; |
| +"Fit column to contents" = "根据内容调整列宽"; |
| +"Font" = "字体"; |
| +"Format" = "格式"; |
| +"Heading %d" = "标题 %d"; |
| +"Home" = "主页"; |
| +"Indent" = "增加缩进"; |
| +"Inline code" = "代码"; |
| +"Inspector" = "文档检查器"; |
| +"Italic" = "斜体"; |
| +"Markdown…" = "Markdown"; |
| +"New document" = "新建文档"; |
| +"No documents" = "还没有文档"; |
| +"No recent documents" = "还没有文档"; |
| +"Normal" = "正文"; |
| +"Numbered list" = "编号列表"; |
| +"Open a document from Home" = "从主页打开文档"; |
| +"Outdent" = "减少缩进"; |
| +"PDF…" = "PDF"; |
| +"Pages" = "分页"; |
| +"Paragraph style" = "段落样式"; |
| +"Print…" = "打印"; |
| +"Recent" = "最近项目"; |
| +"Recent documents" = "最近文档"; |
| +"Recent spreadsheets" = "最近电子表格"; |
| +"Retry" = "重试"; |
| +"Save" = "保存"; |
| +"Select column %@" = "选择 %@ 列"; |
| +"Select row %d" = "选择第 %d 行"; |
| +"Shared" = "与您共享"; |
| +"Share…" = "共享"; |
| +"Sign out" = "退出登录"; |
| +"Size" = "大小"; |
| +"Zoom" = "缩放"; |
diff --git a/native/apple/Tests/DocsKitTests/DocumentRenderReconciliationTests.swift b/native/apple/Tests/DocsKitTests/DocumentRenderReconciliationTests.swift| new file mode 100644 |
| index 0000000..e106250 |
| --- /dev/null |
| +++ b/native/apple/Tests/DocsKitTests/DocumentRenderReconciliationTests.swift |
| @@ -0,0 +1,49 @@ |
| +import Foundation |
| +import XCTest |
| +#if os(macOS) |
| +import AppKit |
| +#endif |
| +@testable import DocsKit |
| + |
| +final class DocumentRenderReconciliationTests: XCTestCase { |
| + @MainActor func testAcknowledgedOptimisticTextUpdatesMappingWithoutReplacingCharacters() { |
| + #if os(macOS) |
| + let storage = NSTextStorage(string: "Hello 😀\n", attributes: [.docsNode: "old", .docsOffset: 0, .font: NSFont.systemFont(ofSize: 12)]) |
| + let observer = StorageObserver() |
| + storage.delegate = observer |
| + let next = NSAttributedString(string: storage.string, attributes: [.docsNode: "published", .docsOffset: 7, .font: NSFont.systemFont(ofSize: 12)]) |
| + DocumentRenderReconciliation.apply(next, to: storage) |
| + XCTAssertEqual(storage.string, next.string) |
| + XCTAssertEqual(storage.attribute(.docsNode, at: 0, effectiveRange: nil) as? String, "published") |
| + XCTAssertEqual(storage.attribute(.docsOffset, at: 0, effectiveRange: nil) as? Int, 7) |
| + XCTAssertFalse(observer.editedCharacters) |
| + XCTAssertTrue(observer.editedAttributes) |
| + #endif |
| + } |
| + @MainActor func testRemoteReplacementPreservesUnicodeAndAllAttributes() { |
| + let storage = NSMutableAttributedString(string: "A😀old Z", attributes: [.docsNode: "old"]) |
| + let next = NSMutableAttributedString(string: "A😁日本語 Z", attributes: [.docsNode: "new"]) |
| + next.addAttribute(.docsOffset, value: 12, range: NSRange(location: 3, length: 3)) |
| + DocumentRenderReconciliation.apply(next, to: storage) |
| + XCTAssertTrue(storage.isEqual(to: next)) |
| + } |
| + #if os(macOS) |
| + @MainActor func testMacLogicalPaperWidthDoesNotReflowAtNarrowZoomedViewport() { |
| + let narrow = DocsPageGeometry(viewportWidth: 350, paged: true) |
| + let wide = DocsPageGeometry(viewportWidth: 1200, paged: true) |
| + XCTAssertEqual(narrow.textWidth, 468) |
| + XCTAssertEqual(wide.textWidth, narrow.textWidth) |
| + XCTAssertEqual(narrow.paperWidth, 612) |
| + } |
| + #endif |
| +} |
| +#if os(macOS) |
| +@MainActor private final class StorageObserver: NSObject, @preconcurrency NSTextStorageDelegate { |
| + var editedCharacters = false |
| + var editedAttributes = false |
| + func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int) { |
| + editedCharacters = editedCharacters || editedMask.contains(.editedCharacters) |
| + editedAttributes = editedAttributes || editedMask.contains(.editedAttributes) |
| + } |
| +} |
| +#endif |
diff --git a/native/apple/Tests/DocsKitTests/DocumentTypographyTests.swift b/native/apple/Tests/DocsKitTests/DocumentTypographyTests.swift| new file mode 100644 |
| index 0000000..cbf9db5 |
| --- /dev/null |
| +++ b/native/apple/Tests/DocsKitTests/DocumentTypographyTests.swift |
| @@ -0,0 +1,47 @@ |
| +#if os(macOS) |
| +import AppKit |
| +import XCTest |
| +@testable import DocsKit |
| + |
| +final class DocumentTypographyTests: XCTestCase { |
| + // Synthetic fixture also measured in WebKit at 96 CSS px/in with the same |
| + // bundled Liberation fonts and the production [docs-text] CSS rules. |
| + @MainActor func testBrowserFixtureLineBreaks() throws { |
| + let heading = "A shared document with consistent typography on every screen" |
| + let bold = "This is a synthetic layout fixture for the native and browser editors. The same bundled font, page width and paragraph styles must produce the same line breaks, including a long sentence that wraps across several lines." |
| + let body = "The quick brown fox jumps over the lazy dog. A small change in font metrics can move the final word onto another line, so this paragraph deliberately includes enough words to exercise wrapping at a fixed document width." |
| + func block(_ id: String, _ text: String, heading: Bool = false, strong: Bool = false) -> CRDTNode { |
| + CRDTNode(id: id, type: heading ? "heading" : "paragraph", attrs: heading ? ["level": "1"] : nil, children: [CRDTNode(id: id + "t", type: "text", runs: [CRDTRun(insert: text, marks: strong ? ["strong": .object([:])] : nil)])]) |
| + } |
| + let projection = CRDTProjection(children: [block("h", heading, heading: true), block("b", bold, strong: true), block("gap", ""), block("p", body)], canUndo: false, canRedo: false) |
| + let storage = NSTextStorage(attributedString: NativeTextProjection.render(projection)) |
| + let manager = NSLayoutManager() |
| + manager.usesFontLeading = false |
| + let container = DocsPageTextContainer(size: CGSize(width: 636, height: 100000)) |
| + container.geometry = DocsPageGeometry(viewportWidth: 636, paged: false) |
| + container.lineFragmentPadding = 0 |
| + storage.addLayoutManager(manager); manager.addTextContainer(container) |
| + manager.ensureLayout(for: container) |
| + var lines: [String] = [] |
| + var heights: [CGFloat] = [] |
| + manager.enumerateLineFragments(forGlyphRange: NSRange(location: 0, length: manager.numberOfGlyphs)) { rect, _, _, range, _ in |
| + let chars = manager.characterRange(forGlyphRange: range, actualGlyphRange: nil) |
| + let line = (storage.string as NSString).substring(with: chars).trimmingCharacters(in: .whitespacesAndNewlines) |
| + if !line.isEmpty { lines.append(line); heights.append(rect.height) } |
| + } |
| + XCTAssertEqual(heights[2], 16.8, accuracy: 0.01) |
| + XCTAssertEqual(heights[3], 16.8, accuracy: 0.01) |
| + XCTAssertEqual(lines, Self.browserLines) |
| + } |
| + private static let browserLines: [String] = [ |
| + "A shared document with consistent typography on", |
| + "every screen", |
| + "This is a synthetic layout fixture for the native and browser editors. The same", |
| + "bundled font, page width and paragraph styles must produce the same line", |
| + "breaks, including a long sentence that wraps across several lines.", |
| + "The quick brown fox jumps over the lazy dog. A small change in font metrics can move", |
| + "the final word onto another line, so this paragraph deliberately includes enough words to", |
| + "exercise wrapping at a fixed document width." |
| +] |
| +} |
| +#endif |
diff --git a/native/apple/Tests/DocsWindowTests/WindowRoutingTests.swift b/native/apple/Tests/DocsWindowTests/WindowRoutingTests.swift| new file mode 100644 |
| index 0000000..cabee15 |
| --- /dev/null |
| +++ b/native/apple/Tests/DocsWindowTests/WindowRoutingTests.swift |
| @@ -0,0 +1,27 @@ |
| +#if os(macOS) |
| +import Foundation |
| +import Testing |
| +import RastrilloApple |
| +@testable import DocsMac |
| + |
| +@MainActor @Test func windowsReuseDocumentsWithinAccountAndOriginOnly() { |
| + let windows = DocsWindows() |
| + let first = NativeHTTP(origin: URL(string: "https://first.invalid")!) |
| + let second = NativeHTTP(origin: URL(string: "https://second.invalid")!) |
| + let document: String = "same" |
| + let id = windows.reserve(account: "a", document: document, title: "One", client: first) |
| + #expect(windows.reserve(account: "a", document: document, title: "Renamed", client: first) == id) |
| + #expect(windows.entries.count == 1) |
| + #expect(!windows.canSignOut("a")) |
| + #expect(windows.reserve(account: "b", document: document, title: "Other account", client: first) != id) |
| + #expect(windows.reserve(account: "a", document: document, title: "Other server", client: second) != id) |
| + #expect(windows.entries.count == 3) |
| + windows.setOpen(id, false) |
| + #expect(!windows.canSignOut("a")) |
| + for entry in windows.entries where entry.account == "a" { windows.setOpen(entry.id, false) } |
| + #expect(windows.canSignOut("a")) |
| + windows.updateRecents([(document, "Current title")], account: "a", client: first) |
| + #expect(windows.entries.first(where: { $0.id == id })?.title == "Current title") |
| + #expect(windows.entries.first(where: { $0.id == id })?.isOpen == false) |
| +} |
| +#endif |