oficina / docs Public

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>
Paul Campbell pushed by paul@keymail.dev 75d4af2f962845c84b7e5b107dec3de6a5b18df9 parent d9aa302
35 files changed, +1224 −48
  • docs/superpowers/specs/2026-09-20-mac-document-windows.md +46 −0
  • native/apple/Package.swift +1 −0
  • native/apple/Sources/DocsApp/DocsAccountWindow.swift +24 −0
  • native/apple/Sources/DocsApp/DocsApp.swift +17 −5
  • native/apple/Sources/DocsApp/DocsDocumentWindow.swift +23 −0
  • native/apple/Sources/DocsApp/DocsWindowCommands.swift +32 −0
  • native/apple/Sources/DocsApp/DocsWindows.swift +47 −0
  • native/apple/Sources/DocsKit/DocsDocumentMenu.swift +26 −0
  • native/apple/Sources/DocsKit/DocsEditorCommands.swift +58 −0
  • native/apple/Sources/DocsKit/DocsEditorContext.swift +20 −0
  • native/apple/Sources/DocsKit/DocsMacStrings.swift +5 −0
  • native/apple/Sources/DocsKit/DocsModel.swift +7 −6
  • native/apple/Sources/DocsKit/DocsPagedTextView.swift +3 −2
  • native/apple/Sources/DocsKit/DocsPaging.swift +7 −1
  • native/apple/Sources/DocsKit/DocsWorkspace.swift +133 −29
  • native/apple/Sources/DocsKit/DocumentExportControls.swift +9 −0
  • native/apple/Sources/DocsKit/DocumentRenderReconciliation.swift +21 −0
  • native/apple/Sources/DocsKit/DocumentThumbnail.swift +3 −1
  • native/apple/Sources/DocsKit/NativeDocumentTextView.swift +16 −2
  • native/apple/Sources/DocsKit/NativeTextProjection.swift +15 −2
  • native/apple/Sources/DocsKit/Resources/ar.lproj/Mac.strings +49 −0
  • native/apple/Sources/DocsKit/Resources/bn.lproj/Mac.strings +49 −0
  • native/apple/Sources/DocsKit/Resources/en.lproj/Mac.strings +49 −0
  • native/apple/Sources/DocsKit/Resources/es.lproj/Mac.strings +49 −0
  • native/apple/Sources/DocsKit/Resources/ga.lproj/Mac.strings +49 −0
  • native/apple/Sources/DocsKit/Resources/hi.lproj/Mac.strings +49 −0
  • native/apple/Sources/DocsKit/Resources/ja.lproj/Mac.strings +49 −0
  • native/apple/Sources/DocsKit/Resources/pt.lproj/Mac.strings +49 −0
  • native/apple/Sources/DocsKit/Resources/ru.lproj/Mac.strings +49 −0
  • native/apple/Sources/DocsKit/Resources/vi.lproj/Mac.strings +49 −0
  • native/apple/Sources/DocsKit/Resources/yue.lproj/Mac.strings +49 −0
  • native/apple/Sources/DocsKit/Resources/zh-Hans.lproj/Mac.strings +49 −0
  • native/apple/Tests/DocsKitTests/DocumentRenderReconciliationTests.swift +49 −0
  • native/apple/Tests/DocsKitTests/DocumentTypographyTests.swift +47 −0
  • native/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