oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1import RastrilloApple
2import 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