| 1 | import RastrilloApple |
| 2 | import SwiftUI |
| 3 | @MainActor public struct DocsWorkspace: View { |
| 4 | @State private var model: DocsModel |
| 5 | @State private var query = "" |
| 6 | @State private var renaming = false |
| 7 | @FocusState private var titleFocused: Bool |
| 8 | private let newDocument: Bool |
| 9 | private let createDocument: (() -> Void)? |
| 10 | private let documentChanged: ((String, String) -> Void)? |
| 11 | @AppStorage("docs.view.paged") private var paged = true |
| 12 | @State private var sharing = false |
| 13 | @State private var exportRequest: String? |
| 14 | @Environment(\.openWindow) private var openWindow |
| 15 | @Environment(\.scenePhase) private var scenePhase |
| 16 | @State private var jumping = false |
| 17 | @State private var discarding = false |
| 18 | @State private var images = DocsImageStore() |
| 19 | @State private var column: NavigationSplitViewColumn = .sidebar |
| 20 | private let updateRecents: (([(String, String)]) -> Void)? |
| 21 | private let canSignOut: Bool |
| 22 | @Binding private var accountsRequested: Bool |
| 23 | @Binding private var createRequested: Bool |
| 24 | private let documentID: String? |
| 25 | private let openDocument: ((String, String) -> Void)? |
| 26 | @State private var zoom = 1.0 |
| 27 | @State private var inspecting = false |
| 28 | private let signOut: () -> Void |
| 29 | private let accounts: (() -> Void)? |
| 30 | private let client: NativeHTTP |
| 31 | /// `transport` supplies the account's current session to the model on every |
| 32 | /// request; without one the model keeps `client` for its whole lifetime. |
| 33 | public init(client: NativeHTTP, accountID: String? = nil, transport: (@MainActor () -> NativeTransport?)? = nil, accounts: (() -> Void)? = nil, signOut: @escaping () -> Void, documentID: String? = nil, newDocument: Bool = false, createDocument: (() -> Void)? = nil, documentChanged: ((String, String) -> Void)? = nil, canSignOut: Bool = true, createRequested: Binding<Bool> = .constant(false), accountsRequested: Binding<Bool> = .constant(false), openDocument: ((String, String) -> Void)? = nil, updateRecents: (([(String, String)]) -> Void)? = nil) { |
| 34 | self.updateRecents = updateRecents; self.canSignOut = canSignOut; self._createRequested = createRequested; self._accountsRequested = accountsRequested; self.documentID = documentID; self.openDocument = openDocument |
| 35 | self.newDocument = newDocument; self.createDocument = createDocument; self.documentChanged = documentChanged |
| 36 | let offline = accountID.map { DocsOfflineAccount.account($0, origin: client.origin, fallback: client, transport: transport) } |
| 37 | _model = State(initialValue: DocsModel(transport: transport ?? { client }, draft: newDocument, offlineAccount: offline)); self.signOut = signOut; self.accounts = accounts; self.client = client |
| 38 | } |
| 39 | private var library: some View { |
| 40 | List { |
| 41 | ForEach(model.documents) { document in |
| 42 | Button { Task { await model.open(document.sqid) } } label: { |
| 43 | HStack { DocumentThumbnail(data: document.thumbnail); Text(document.title); if document.shared == true { Image(systemName: "person.2") } } |
| 44 | }.buttonStyle(.plain) |
| 45 | } |
| 46 | } |
| 47 | .searchable(text: $query, prompt: Text("docs.list.search.label", bundle: DocsResources.bundle)) |
| 48 | .onSubmit(of: .search) { Task { await model.load(query: query) } } |
| 49 | .navigationTitle(Text("native.docs", bundle: DocsResources.bundle)) |
| 50 | .toolbar { |
| 51 | if let accounts { |
| 52 | ToolbarItem(placement: .navigation) { |
| 53 | Button(docsMacString("Accounts"), systemImage: "chevron.backward", action: accounts).disabled(model.hasUnsavedChanges || model.creatingDocument) |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | ToolbarItemGroup { |
| 58 | DocsJumpControls(presented: $jumping) |
| 59 | if model.canCreate { Button { beginDocument() } label: { Label { Text("docs.list.create.label", bundle: DocsResources.bundle) } icon: { Image(systemName: "plus") } }.accessibilityIdentifier("docs.create") } |
| 60 | Button { if !model.hasUnsavedChanges && !model.creatingDocument { signOut() } } label: { Text("docs.signout.label", bundle: DocsResources.bundle) }.disabled(model.hasUnsavedChanges || model.creatingDocument) |
| 61 | } |
| 62 | } |
| 63 | } |
| 64 | public var body: some View { |
| 65 | Group { |
| 66 | #if os(macOS) |
| 67 | if documentID != nil || newDocument { editor } else { home } |
| 68 | #else |
| 69 | NavigationSplitView(preferredCompactColumn: $column) { library } detail: { editor } |
| 70 | #endif |
| 71 | } |
| 72 | .safeAreaInset(edge: .top, spacing: 0) { |
| 73 | NativeOfflineBanner(connectivity: model.connectivity, origin: client.origin, summary: model.outboxSummary) |
| 74 | } |
| 75 | |
| 76 | .alert(NativeSyncStrings.kit.unsentLost, isPresented: Binding( |
| 77 | get: { model.lostChangesNotice != nil }, |
| 78 | set: { showing in if !showing { Task { await model.dismissLostChangesNotice() } } } |
| 79 | )) { |
| 80 | Button { Task { await model.dismissLostChangesNotice() } } label: { |
| 81 | Text("native.close", bundle: DocsResources.bundle) |
| 82 | } |
| 83 | } |
| 84 | .onChange(of: model.documents.map { "\($0.sqid)\u{0}\($0.title)" }) { |
| 85 | if query.isEmpty { updateRecents?(model.documents.map { ($0.sqid, $0.title) }) } |
| 86 | } |
| 87 | .onChange(of: accountsRequested && !model.creatingDocument, initial: true) { _, ready in |
| 88 | if ready { accountsRequested = false; accounts?() } |
| 89 | } |
| 90 | .onChange(of: createRequested, initial: true) { _, requested in |
| 91 | if requested && documentID == nil { beginDocument(); createRequested = false } |
| 92 | } |
| 93 | .task { if newDocument { await model.newDraft() } else if let documentID { await model.open(documentID) } else { await model.load() } } |
| 94 | .onChange(of: model.renameInput) { _, value in if value == nil { renaming = false } } |
| 95 | .onChange(of: [model.detail?.sqid, model.detail?.title]) { |
| 96 | if !model.isDraft, let detail = model.detail { documentChanged?(detail.sqid, detail.title) } |
| 97 | } |
| 98 | .sheet(isPresented: $jumping) { DocsJumpSheet(model: model, client: client) } |
| 99 | .onDisappear { Task { await model.stop() } } |
| 100 | .onChange(of: scenePhase) { _, phase in |
| 101 | if phase == .background { Task { await model.persistReplica() } } |
| 102 | if phase == .active { Task { try? await model.offlineAccount.activate() } } |
| 103 | } |
| 104 | #if os(macOS) |
| 105 | .background { DocsCloseGuard(hasUnsaved: { model.hasUnsavedChanges }, save: { await model.saveAll() }, discard: { await model.discardAll() }).frame(width: 0, height: 0) } |
| 106 | #endif |
| 107 | .onChange(of: model.detail?.sqid) { if model.detail != nil { column = .detail } } |
| 108 | .confirmationDialog(Text("native.discard.title", bundle: DocsResources.bundle), isPresented: $discarding, titleVisibility: .visible) { |
| 109 | Button(role: .destructive) { Task { await model.discardAll(); column = .sidebar } } label: { Text("native.discard.action", bundle: DocsResources.bundle) } |
| 110 | Button(role: .cancel) {} label: { Text("native.close.pending.cancel", bundle: DocsResources.bundle) } |
| 111 | } message: { |
| 112 | Text("native.close.pending.detail", bundle: DocsResources.bundle) |
| 113 | } |
| 114 | .sheet(isPresented: $sharing) { |
| 115 | if let detail = model.detail { DocsSharingView(client: client, sqid: detail.sqid) } |
| 116 | } |
| 117 | } |
| 118 | private func beginDocument() { |
| 119 | #if os(macOS) |
| 120 | createDocument?() |
| 121 | #else |
| 122 | Task { |
| 123 | guard await model.saveAll() else { return } |
| 124 | await model.discardAll() |
| 125 | await model.newDraft() |
| 126 | } |
| 127 | #endif |
| 128 | } |
| 129 | private func beginRename() { |
| 130 | model.renameInput = model.detail?.title ?? "" |
| 131 | renaming = true; titleFocused = true |
| 132 | } |
| 133 | private func finishRename() { |
| 134 | guard renaming else { return } |
| 135 | if model.commitRename() { renaming = false } |
| 136 | } |
| 137 | private var documentTitle: some View { |
| 138 | Group { |
| 139 | if renaming { |
| 140 | TextField(String(localized: "docs.list.create.placeholder", bundle: DocsResources.bundle), text: Binding(get: { model.renameInput ?? model.detail?.title ?? "" }, set: { model.renameInput = $0 })) |
| 141 | .accessibilityIdentifier("docs.document.title") |
| 142 | .textFieldStyle(.plain).frame(minWidth: 180, maxWidth: 480) |
| 143 | .focused($titleFocused).onSubmit { finishRename() } |
| 144 | .onChange(of: titleFocused) { _, focused in if !focused { finishRename() } } |
| 145 | #if os(macOS) |
| 146 | .onExitCommand { model.renameInput = nil; renaming = false } |
| 147 | #endif |
| 148 | } else { |
| 149 | Text(model.detail?.title ?? " ") |
| 150 | .lineLimit(1).frame(minHeight: 20) |
| 151 | .onTapGesture(count: 2) { if model.detail?.can_write == true { beginRename() } } |
| 152 | .contextMenu { |
| 153 | Button { beginRename() } label: { Text("docs.document.rename", bundle: DocsResources.bundle) } |
| 154 | .disabled(model.detail?.can_write != true) |
| 155 | .keyboardShortcut("r", modifiers: [.command, .shift]) |
| 156 | } |
| 157 | .accessibilityAction(named: Text("docs.document.rename", bundle: DocsResources.bundle)) { beginRename() } |
| 158 | } |
| 159 | } |
| 160 | } |
| 161 | private var editor: some View { |
| 162 | VStack(spacing: 0) { |
| 163 | NativeSyncStatusView(status: model.syncStatus) |
| 164 | if let key = model.errorKey { |
| 165 | HStack { |
| 166 | Text(LocalizedStringKey(key), bundle: DocsResources.bundle) |
| 167 | Button { Task { await model.retry() } } label: { Text("native.retry", bundle: DocsResources.bundle) } |
| 168 | if model.hasUnsavedChanges { |
| 169 | Button(role: .destructive) { discarding = true } label: { Text("native.discard.action", bundle: DocsResources.bundle) } |
| 170 | } |
| 171 | }.padding().accessibilityElement(children: .contain) |
| 172 | } |
| 173 | if model.loading { ProgressView() } |
| 174 | if let projection = model.projection { |
| 175 | let documentPath = "/d/" + (model.detail?.sqid ?? "") |
| 176 | let text = NativeTextProjection.render(projection, images: images.images(forDocumentPath: documentPath, client: client)) |
| 177 | EditorToolbar(model: model, content: text) |
| 178 | Divider() |
| 179 | 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 |
| 180 | if action == "jump" { |
| 181 | #if os(macOS) |
| 182 | openWindow(id: "home") |
| 183 | #else |
| 184 | jumping = true |
| 185 | #endif |
| 186 | return true |
| 187 | } |
| 188 | return DocumentCommands(model: model, content: text).perform(action) |
| 189 | }, 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) |
| 190 | .id(model.editorIdentity) |
| 191 | .task(id: [documentPath] + DocsImageStore.sources(in: projection)) { await images.load(sources: DocsImageStore.sources(in: projection), client: client, documentPath: documentPath) } |
| 192 | } else { |
| 193 | ContentUnavailableView { Label { Text("native.open", bundle: DocsResources.bundle) } icon: { Image(systemName: "doc.text") } } |
| 194 | } |
| 195 | }.navigationTitle(model.detail?.title ?? "") |
| 196 | .toolbar { ToolbarItem(placement: .principal) { documentTitle } } |
| 197 | #if os(iOS) |
| 198 | .navigationBarTitleDisplayMode(.inline) |
| 199 | #endif |
| 200 | #if os(iOS) |
| 201 | .toolbar { |
| 202 | DocsJumpControls(presented: $jumping) |
| 203 | if let detail = model.detail, !model.isDraft { |
| 204 | Toggle(isOn: $paged) { Text("docs.editor.menu.view.paged", bundle: DocsResources.bundle) } |
| 205 | DocumentExportControls(client: client, sqid: detail.sqid, title: detail.title) { |
| 206 | return await model.saveAll() |
| 207 | }.id(detail.sqid) |
| 208 | if let projection = model.projection { DocsToolsControls(projection: projection, content: NativeTextProjection.render(projection), selection: model.selection) } |
| 209 | } |
| 210 | if model.detail?.can_share == true { |
| 211 | Button { |
| 212 | Task { await model.flush(); if !model.hasUnsavedChanges { sharing = true } } |
| 213 | } label: { Label { Text("docs.share.title", bundle: DocsResources.bundle) } icon: { Image(systemName: "person.badge.plus") } } |
| 214 | } |
| 215 | } |
| 216 | #else |
| 217 | .background { |
| 218 | if let detail = model.detail, !model.isDraft { |
| 219 | DocumentExportControls(client: client, sqid: detail.sqid, title: detail.title, prepare: { await model.saveAll() }, request: $exportRequest) |
| 220 | .frame(width: 0, height: 0).hidden().accessibilityHidden(true) |
| 221 | } |
| 222 | } |
| 223 | .toolbar { Button(docsMacString("Inspector"), systemImage: "sidebar.right") { inspecting.toggle() } } |
| 224 | // A closed SwiftUI inspector can repeatedly invalidate hosting-view |
| 225 | // constraints on macOS 27 while the editor loads. Keep this panel in |
| 226 | // the ordinary view layout so opening a document has stable sizing. |
| 227 | .safeAreaInset(edge: .trailing, spacing: 0) { |
| 228 | if inspecting { |
| 229 | Form { |
| 230 | LabeledContent(docsMacString("Document"), value: model.detail?.title ?? "") |
| 231 | Picker(docsMacString("Zoom"), selection: $zoom) { |
| 232 | ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 2.0], id: \.self) { value in |
| 233 | Text(value, format: .percent).tag(value) |
| 234 | } |
| 235 | } |
| 236 | Toggle(docsMacString("Pages"), isOn: $paged) |
| 237 | if let projection = model.projection { |
| 238 | DocsToolsControls(projection: projection, content: NativeTextProjection.render(projection), selection: model.selection) |
| 239 | } |
| 240 | }.formStyle(.grouped).frame(width: 280) |
| 241 | .frame(maxHeight: .infinity) |
| 242 | .background(.background) |
| 243 | .overlay(alignment: .leading) { Divider() } |
| 244 | } |
| 245 | } |
| 246 | .focusedSceneValue(\.docsEditor, DocsEditorContext(model: model, rename: beginRename, client: client, zoom: $zoom, paged: $paged, inspecting: $inspecting, sharing: $sharing, exportRequest: $exportRequest)) |
| 247 | #endif |
| 248 | |
| 249 | } |
| 250 | #if os(macOS) |
| 251 | private var home: some View { |
| 252 | NavigationSplitView { |
| 253 | List { Label(docsMacString("Recent"), systemImage: "clock").listRowBackground(Color.accentColor.opacity(0.12)) } |
| 254 | .navigationSplitViewColumnWidth(min: 160, ideal: 190, max: 240) |
| 255 | } detail: { |
| 256 | ScrollView { |
| 257 | VStack(alignment: .leading, spacing: 24) { |
| 258 | Text(docsMacString("Recent documents")).font(.largeTitle.bold()) |
| 259 | if let error = model.errorKey { |
| 260 | Text(LocalizedStringKey(error), bundle: DocsResources.bundle).foregroundStyle(.red) |
| 261 | Button(docsMacString("Retry")) { Task { await model.load(query: query) } } |
| 262 | } |
| 263 | LazyVGrid(columns: [GridItem(.adaptive(minimum: 180, maximum: 240))], spacing: 24) { |
| 264 | ForEach(model.documents) { document in |
| 265 | Button { openDocument?(document.sqid, document.title) } label: { |
| 266 | VStack(alignment: .leading, spacing: 10) { |
| 267 | DocumentThumbnail(data: document.thumbnail, width: 170, height: 220) |
| 268 | Text(document.title).font(.headline).lineLimit(2) |
| 269 | if document.shared == true { Label(docsMacString("Shared"), systemImage: "person.2").font(.caption) } |
| 270 | }.frame(maxWidth: .infinity, alignment: .leading).padding(12) |
| 271 | }.buttonStyle(.plain) |
| 272 | } |
| 273 | } |
| 274 | if model.listState == .loading { ProgressView().frame(maxWidth: .infinity) } |
| 275 | if model.listState == .loaded && model.documents.isEmpty && !query.isEmpty { |
| 276 | ContentUnavailableView { Text("docs.list.search.none.title", bundle: DocsResources.bundle) } description: { Text("docs.list.search.none.body", bundle: DocsResources.bundle) } |
| 277 | } |
| 278 | if model.listState == .loaded && model.documents.isEmpty && query.isEmpty { |
| 279 | ContentUnavailableView(docsMacString("No documents"), systemImage: "doc.text", description: Text(docsMacString("Create a document to get started."))) |
| 280 | } |
| 281 | }.padding(28) |
| 282 | } |
| 283 | .navigationTitle(docsMacString("Home")) |
| 284 | .searchable(text: $query, prompt: Text("docs.list.search.label", bundle: DocsResources.bundle)) |
| 285 | .onSubmit(of: .search) { Task { await model.load(query: query) } } |
| 286 | .focusedSceneValue(\.docsAccountActions, DocsAccountActions(accounts: accounts, signOut: signOut, canManage: !model.creatingDocument, canSignOut: canSignOut && !model.creatingDocument)) |
| 287 | .toolbar { |
| 288 | Button(docsMacString("New document"), systemImage: "plus") { beginDocument() }.disabled(!model.canCreate) |
| 289 | } |
| 290 | } |
| 291 | } |
| 292 | #endif |
| 293 | |
| 294 | } |
| 295 | |