import RastrilloApple import SwiftUI @MainActor public struct DocsWorkspace: View { @State private var model: DocsModel @State private var query = "" @State private var renaming = false @FocusState private var titleFocused: Bool private let newDocument: Bool private let createDocument: (() -> Void)? private let documentChanged: ((String, String) -> Void)? @AppStorage("docs.view.paged") private var paged = true @State private var sharing = false @State private var exportRequest: String? @Environment(\.openWindow) private var openWindow @Environment(\.scenePhase) private var scenePhase @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 accountsRequested: 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 /// `transport` supplies the account's current session to the model on every /// request; without one the model keeps `client` for its whole lifetime. 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 = .constant(false), accountsRequested: Binding = .constant(false), openDocument: ((String, String) -> Void)? = nil, updateRecents: (([(String, String)]) -> Void)? = nil) { self.updateRecents = updateRecents; self.canSignOut = canSignOut; self._createRequested = createRequested; self._accountsRequested = accountsRequested; self.documentID = documentID; self.openDocument = openDocument self.newDocument = newDocument; self.createDocument = createDocument; self.documentChanged = documentChanged let offline = accountID.map { DocsOfflineAccount.account($0, origin: client.origin, fallback: client, transport: transport) } _model = State(initialValue: DocsModel(transport: transport ?? { client }, draft: newDocument, offlineAccount: offline)); self.signOut = signOut; self.accounts = accounts; self.client = client } private var library: some View { List { ForEach(model.documents) { document in Button { Task { await model.open(document.sqid) } } label: { HStack { DocumentThumbnail(data: document.thumbnail); Text(document.title); if document.shared == true { Image(systemName: "person.2") } } }.buttonStyle(.plain) } } .searchable(text: $query, prompt: Text("docs.list.search.label", bundle: DocsResources.bundle)) .onSubmit(of: .search) { Task { await model.load(query: query) } } .navigationTitle(Text("native.docs", bundle: DocsResources.bundle)) .toolbar { if let accounts { ToolbarItem(placement: .navigation) { Button(docsMacString("Accounts"), systemImage: "chevron.backward", action: accounts).disabled(model.hasUnsavedChanges || model.creatingDocument) } } ToolbarItemGroup { DocsJumpControls(presented: $jumping) if model.canCreate { Button { beginDocument() } label: { Label { Text("docs.list.create.label", bundle: DocsResources.bundle) } icon: { Image(systemName: "plus") } }.accessibilityIdentifier("docs.create") } Button { if !model.hasUnsavedChanges && !model.creatingDocument { signOut() } } label: { Text("docs.signout.label", bundle: DocsResources.bundle) }.disabled(model.hasUnsavedChanges || model.creatingDocument) } } } public var body: some View { Group { #if os(macOS) if documentID != nil || newDocument { editor } else { home } #else NavigationSplitView(preferredCompactColumn: $column) { library } detail: { editor } #endif } .safeAreaInset(edge: .top, spacing: 0) { NativeOfflineBanner(connectivity: model.connectivity, origin: client.origin, summary: model.outboxSummary) } .alert(NativeSyncStrings.kit.unsentLost, isPresented: Binding( get: { model.lostChangesNotice != nil }, set: { showing in if !showing { Task { await model.dismissLostChangesNotice() } } } )) { Button { Task { await model.dismissLostChangesNotice() } } label: { Text("native.close", bundle: DocsResources.bundle) } } .onChange(of: model.documents.map { "\($0.sqid)\u{0}\($0.title)" }) { if query.isEmpty { updateRecents?(model.documents.map { ($0.sqid, $0.title) }) } } .onChange(of: accountsRequested && !model.creatingDocument, initial: true) { _, ready in if ready { accountsRequested = false; accounts?() } } .onChange(of: createRequested, initial: true) { _, requested in if requested && documentID == nil { beginDocument(); createRequested = false } } .task { if newDocument { await model.newDraft() } else if let documentID { await model.open(documentID) } else { await model.load() } } .onChange(of: model.renameInput) { _, value in if value == nil { renaming = false } } .onChange(of: [model.detail?.sqid, model.detail?.title]) { if !model.isDraft, let detail = model.detail { documentChanged?(detail.sqid, detail.title) } } .sheet(isPresented: $jumping) { DocsJumpSheet(model: model, client: client) } .onDisappear { Task { await model.stop() } } .onChange(of: scenePhase) { _, phase in if phase == .background { Task { await model.persistReplica() } } if phase == .active { Task { try? await model.offlineAccount.activate() } } } #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) } } } private func beginDocument() { #if os(macOS) createDocument?() #else Task { guard await model.saveAll() else { return } await model.discardAll() await model.newDraft() } #endif } private func beginRename() { model.renameInput = model.detail?.title ?? "" renaming = true; titleFocused = true } private func finishRename() { guard renaming else { return } if model.commitRename() { renaming = false } } private var documentTitle: some View { Group { if renaming { TextField(String(localized: "docs.list.create.placeholder", bundle: DocsResources.bundle), text: Binding(get: { model.renameInput ?? model.detail?.title ?? "" }, set: { model.renameInput = $0 })) .accessibilityIdentifier("docs.document.title") .textFieldStyle(.plain).frame(minWidth: 180, maxWidth: 480) .focused($titleFocused).onSubmit { finishRename() } .onChange(of: titleFocused) { _, focused in if !focused { finishRename() } } #if os(macOS) .onExitCommand { model.renameInput = nil; renaming = false } #endif } else { Text(model.detail?.title ?? " ") .lineLimit(1).frame(minHeight: 20) .onTapGesture(count: 2) { if model.detail?.can_write == true { beginRename() } } .contextMenu { Button { beginRename() } label: { Text("docs.document.rename", bundle: DocsResources.bundle) } .disabled(model.detail?.can_write != true) .keyboardShortcut("r", modifiers: [.command, .shift]) } .accessibilityAction(named: Text("docs.document.rename", bundle: DocsResources.bundle)) { beginRename() } } } } private var editor: some View { VStack(spacing: 0) { NativeSyncStatusView(status: model.syncStatus) if let key = model.errorKey { HStack { Text(LocalizedStringKey(key), bundle: DocsResources.bundle) Button { Task { await model.retry() } } label: { Text("native.retry", bundle: DocsResources.bundle) } if model.hasUnsavedChanges { Button(role: .destructive) { discarding = true } label: { Text("native.discard.action", bundle: DocsResources.bundle) } } }.padding().accessibilityElement(children: .contain) } if model.loading { ProgressView() } if let projection = model.projection { let documentPath = "/d/" + (model.detail?.sqid ?? "") let text = NativeTextProjection.render(projection, images: images.images(forDocumentPath: documentPath, client: client)) EditorToolbar(model: model, content: text) Divider() 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" { #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, zoom: zoom) .id(model.editorIdentity) .task(id: [documentPath] + DocsImageStore.sources(in: projection)) { await images.load(sources: DocsImageStore.sources(in: projection), client: client, documentPath: documentPath) } } else { ContentUnavailableView { Label { Text("native.open", bundle: DocsResources.bundle) } icon: { Image(systemName: "doc.text") } } } }.navigationTitle(model.detail?.title ?? "") .toolbar { ToolbarItem(placement: .principal) { documentTitle } } #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif #if os(iOS) .toolbar { DocsJumpControls(presented: $jumping) if let detail = model.detail, !model.isDraft { Toggle(isOn: $paged) { Text("docs.editor.menu.view.paged", bundle: DocsResources.bundle) } DocumentExportControls(client: client, sqid: detail.sqid, title: detail.title) { return await model.saveAll() }.id(detail.sqid) if let projection = model.projection { DocsToolsControls(projection: projection, content: NativeTextProjection.render(projection), selection: model.selection) } } if model.detail?.can_share == true { Button { Task { await model.flush(); if !model.hasUnsavedChanges { sharing = true } } } label: { Label { Text("docs.share.title", bundle: DocsResources.bundle) } icon: { Image(systemName: "person.badge.plus") } } } } #else .background { if let detail = model.detail, !model.isDraft { 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() } } // A closed SwiftUI inspector can repeatedly invalidate hosting-view // constraints on macOS 27 while the editor loads. Keep this panel in // the ordinary view layout so opening a document has stable sizing. .safeAreaInset(edge: .trailing, spacing: 0) { if 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).frame(width: 280) .frame(maxHeight: .infinity) .background(.background) .overlay(alignment: .leading) { Divider() } } } .focusedSceneValue(\.docsEditor, DocsEditorContext(model: model, rename: beginRename, 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.listState == .loading { ProgressView().frame(maxWidth: .infinity) } if model.listState == .loaded && model.documents.isEmpty && !query.isEmpty { ContentUnavailableView { Text("docs.list.search.none.title", bundle: DocsResources.bundle) } description: { Text("docs.list.search.none.body", bundle: DocsResources.bundle) } } if model.listState == .loaded && model.documents.isEmpty && query.isEmpty { ContentUnavailableView(docsMacString("No documents"), systemImage: "doc.text", description: Text(docsMacString("Create a document to get started."))) } }.padding(28) } .navigationTitle(docsMacString("Home")) .searchable(text: $query, prompt: Text("docs.list.search.label", bundle: DocsResources.bundle)) .onSubmit(of: .search) { Task { await model.load(query: query) } } .focusedSceneValue(\.docsAccountActions, DocsAccountActions(accounts: accounts, signOut: signOut, canManage: !model.creatingDocument, canSignOut: canSignOut && !model.creatingDocument)) .toolbar { Button(docsMacString("New document"), systemImage: "plus") { beginDocument() }.disabled(!model.canCreate) } } } #endif }