1import AppKit
2import CoreText
3import UniformTypeIdentifiers
4
5/// Disk store + renderer for storyboard boards. The raster (drawing) layer of
6/// each board lives in memory during a session and flushes to a PNG beside the
7/// project file — `{project}/Storyboard/NN.png`, where NN is the panel's
8/// position in the storyboard. The shape layer lives in the project model.
9/// Composites are cached per revision.
10final class BoardStore {
11 /// The document context that owns this raster store. Set at construction.
12 unowned var ctx: DocumentContext!
13
14 /// Default board background — #e8e8e8, softer than pure white.
15 static let paper = NSColor(calibratedRed: 0xE8 / 255.0, green: 0xE8 / 255.0,
16 blue: 0xE8 / 255.0, alpha: 1)
17
18 /// Pre-2026 rasters keyed by board UUID under Application Support. Read on
19 /// demand so old projects keep their drawings; rewritten to the new
20 /// project-relative path on the next save.
21 private let legacyBoardsDir: URL
22 /// In-session source of truth for drawing layers, keyed by board id. Disk
23 /// is written only at save time (`flushRasters`), so unsaved projects keep
24 /// their drawings purely here.
25 private var rasterCache: [UUID: NSImage] = [:]
26 private var compositeCache = NSCache<NSString, NSImage>()
27 /// Bumped on every raster save so composites invalidate without touching
28 /// the model (strokes are editor-local, not undo entries).
29 private var rasterVersions: [UUID: Int] = [:]
30
31 init() {
32 let appSupport = FileManager.default.urls(for: .applicationSupportDirectory,
33 in: .userDomainMask)[0]
34 .appendingPathComponent("Sequencer", isDirectory: true)
35 legacyBoardsDir = appSupport.appendingPathComponent("Boards", isDirectory: true)
36 compositeCache.countLimit = 200
37 }
38
39 /// 1-based ordinal of a board among the project's storyboard panels, or nil
40 /// if it isn't attached to a panel yet (freshly created, mid-paste).
41 private func boardIndex(_ boardId: UUID, in model: ProjectModel) -> Int? {
42 let panels = model.orderedStoryboardPanels()
43 guard let i = panels.firstIndex(where: { $0.board?.id == boardId }) else { return nil }
44 return i + 1
45 }
46
47 func rasterImage(_ boardId: UUID) -> NSImage? {
48 if let img = rasterCache[boardId] { return img }
49 // Drawings are loaded into the cache from the document package when it
50 // opens (`loadRasters`); the only on-demand read left is the legacy
51 // pre-2026 store keyed by board UUID under Application Support.
52 let legacy = legacyBoardsDir.appendingPathComponent("\(boardId.uuidString).png")
53 if let img = NSImage(contentsOf: legacy) {
54 rasterCache[boardId] = img
55 return img
56 }
57 return nil
58 }
59
60 func saveRaster(_ image: NSImage?, boardId: UUID) {
61 rasterVersions[boardId, default: 0] += 1
62 if let image {
63 rasterCache[boardId] = image
64 } else {
65 rasterCache.removeValue(forKey: boardId)
66 }
67 // Persist lazily: strokes bypass `Store.mutate`, so nudge the document
68 // dirty and let autosave flush the raster to disk.
69 ctx.store.noteRasterChanged()
70 }
71
72 func duplicateRaster(from: UUID, to: UUID) {
73 rasterVersions[to, default: 0] += 1
74 if let img = rasterImage(from)?.copy() as? NSImage {
75 rasterCache[to] = img
76 } else {
77 rasterCache.removeValue(forKey: to)
78 }
79 ctx.store.noteRasterChanged()
80 }
81
82 /// Encode a board's drawing layer as PNG bytes (clipboard transfer).
83 func rasterPNGData(_ boardId: UUID) -> Data? {
84 guard let img = rasterImage(boardId),
85 let tiff = img.tiffRepresentation,
86 let rep = NSBitmapImageRep(data: tiff) else { return nil }
87 return rep.representation(using: .png, properties: [:])
88 }
89
90 /// Install a drawing layer from clipboard PNG bytes.
91 func setRaster(fromPNG data: Data, boardId: UUID) {
92 guard let img = NSImage(data: data) else { return }
93 saveRaster(img, boardId: boardId)
94 }
95
96 /// PNG data for each panel's drawing, keyed `NN.png` by storyboard order —
97 /// the contents of the document package's `Storyboard/` directory. Panels
98 /// with no drawing are omitted.
99 func rasterPNGs(of model: ProjectModel) -> [String: Data] {
100 var out: [String: Data] = [:]
101 for (i, panel) in model.orderedStoryboardPanels().enumerated() {
102 guard let board = panel.board,
103 let img = rasterImage(board.id),
104 let tiff = img.tiffRepresentation,
105 let rep = NSBitmapImageRep(data: tiff),
106 let png = rep.representation(using: .png, properties: [:]) else { continue }
107 out[String(format: "%02d.png", i + 1)] = png
108 }
109 return out
110 }
111
112 /// Load panel drawings from a `Storyboard/` directory (inside the package,
113 /// or the legacy sibling folder during migration) into the raster cache,
114 /// mapping `NN.png` back to the board at that storyboard position.
115 func loadRasters(fromDirectory dir: URL, project: ProjectModel) {
116 let panels = project.orderedStoryboardPanels()
117 for (i, panel) in panels.enumerated() {
118 guard let board = panel.board else { continue }
119 let url = dir.appendingPathComponent(String(format: "%02d.png", i + 1))
120 if let img = NSImage(contentsOf: url) {
121 rasterCache[board.id] = img
122 rasterVersions[board.id, default: 0] += 1
123 }
124 }
125 }
126
127 func invalidate(_ boardId: UUID) {
128 rasterVersions[boardId, default: 0] += 1
129 rasterCache.removeValue(forKey: boardId)
130 }
131
132 // MARK: - Stroke engine (shared by the editor canvas and viewer cells)
133
134 // Board coordinates everywhere: top-left origin, y down. The engine owns
135 // the y-flip into image space so callers never think about it.
136 private var workingRasters: [UUID: NSImage] = [:]
137 /// Pre-stroke drawing captured at `beginStroke`, used by `endStroke` to push
138 /// one undo entry onto the shared `Store` timeline.
139 private var pendingStrokeBefore: [UUID: NSImage?] = [:]
140
141 /// Raster to DISPLAY: the in-progress stroke image when one is active.
142 func displayRaster(_ boardId: UUID) -> NSImage? {
143 workingRasters[boardId] ?? rasterImage(boardId)
144 }
145
146 private func blankRaster(size: CGSize) -> NSImage {
147 let img = NSImage(size: size)
148 img.lockFocus()
149 NSColor.clear.setFill()
150 NSRect(origin: .zero, size: size).fill()
151 img.unlockFocus()
152 return img
153 }
154
155 func beginStroke(board: Board) {
156 // Remember the pre-stroke drawing so the commit can register one undo
157 // step on the shared timeline.
158 pendingStrokeBefore[board.id] = rasterImage(board.id)?.copy() as? NSImage
159 workingRasters[board.id] = (rasterImage(board.id)?.copy() as? NSImage)
160 ?? blankRaster(size: board.size)
161 }
162
163 /// Add a segment in board coords. `pressure` scales the width (tablets).
164 func strokeSegment(board: Board, from a: CGPoint, to b: CGPoint,
165 width: CGFloat, color: NSColor, erase: Bool,
166 alpha: CGFloat = 1, pressure: CGFloat = 0) {
167 guard let img = workingRasters[board.id] else { return }
168 let h = board.size.height
169 // Image focus is bottom-left; board coords are top-left.
170 let a2 = CGPoint(x: a.x, y: h - a.y)
171 let b2 = CGPoint(x: b.x, y: h - b.y)
172 img.lockFocus()
173 if let ctx = NSGraphicsContext.current {
174 ctx.compositingOperation = erase ? .destinationOut : .sourceOver
175 }
176 let path = NSBezierPath()
177 path.move(to: a2)
178 // Zero-length segments (clicks) still leave a dot.
179 path.line(to: a2 == b2 ? CGPoint(x: b2.x + 0.3, y: b2.y) : b2)
180 var w = width
181 if pressure > 0.01, pressure < 0.999 { w = width * (0.35 + 1.3 * pressure) }
182 path.lineWidth = w
183 path.lineCapStyle = .round
184 path.lineJoinStyle = .round
185 (erase ? NSColor.black : color).withAlphaComponent(alpha).setStroke()
186 path.stroke()
187 img.unlockFocus()
188 workingRasters[board.id] = img
189 }
190
191 func endStroke(board: Board) {
192 guard let img = workingRasters.removeValue(forKey: board.id) else { return }
193 saveRaster(img, boardId: board.id)
194 // Register the completed stroke on the shared undo timeline.
195 let before = pendingStrokeBefore.removeValue(forKey: board.id) ?? nil
196 ctx.store.recordRasterEdit(boardId: board.id, before: before)
197 NotificationCenter.default.post(name: .mediaStatusChanged, object: nil, userInfo: ["scene": true])
198 }
199
200 /// A copy of the current drawing for the undo timeline (nil = blank board).
201 func rasterSnapshot(_ boardId: UUID) -> NSImage? {
202 rasterImage(boardId)?.copy() as? NSImage
203 }
204
205 /// Restore a drawing captured by `rasterSnapshot` (undo/redo of a stroke),
206 /// dropping any in-progress stroke and refreshing the editor + viewer.
207 func applyRaster(_ image: NSImage?, boardId: UUID) {
208 workingRasters.removeValue(forKey: boardId)
209 saveRaster(image, boardId: boardId)
210 NotificationCenter.default.post(name: .projectChanged, object: nil)
211 }
212
213 /// Wipe the drawing layer (undoable as one stroke).
214 func clearRaster(board: Board) {
215 beginStroke(board: board)
216 workingRasters[board.id] = blankRaster(size: board.size)
217 endStroke(board: board)
218 }
219
220 // MARK: - Rendering
221
222 /// Flattened panel: white paper, below-raster shapes, raster, above-raster
223 /// shapes. Cached by board id + revision.
224 func composite(for board: Board) -> NSImage {
225 let rv = rasterVersions[board.id] ?? 0
226 let key = "\(board.id.uuidString)/\(board.revision)/\(rv)" as NSString
227 // Mid-stroke: render fresh for live feedback, don't poison the cache.
228 let strokeInProgress = workingRasters[board.id] != nil
229 if !strokeInProgress, let img = compositeCache.object(forKey: key) { return img }
230 let img = NSImage(size: board.size, flipped: true) { rect in
231 Self.paper.setFill()
232 rect.fill()
233 for shape in board.shapes where !shape.aboveRaster {
234 Self.draw(shape)
235 }
236 if let raster = self.displayRaster(board.id) {
237 raster.draw(in: rect, from: .zero, operation: .sourceOver,
238 fraction: 1, respectFlipped: true, hints: nil)
239 }
240 for shape in board.shapes where shape.aboveRaster {
241 Self.draw(shape)
242 }
243 return true
244 }
245 if !strokeInProgress { compositeCache.setObject(img, forKey: key) }
246 return img
247 }
248
249 static func rgba(_ color: NSColor) -> [Double] {
250 let c = color.usingColorSpace(.sRGB) ?? color
251 return [Double(c.redComponent), Double(c.greenComponent),
252 Double(c.blueComponent), Double(c.alphaComponent)]
253 }
254
255 static func color(_ rgba: [Double]) -> NSColor {
256 NSColor(calibratedRed: rgba.count > 0 ? rgba[0] : 0,
257 green: rgba.count > 1 ? rgba[1] : 0,
258 blue: rgba.count > 2 ? rgba[2] : 0,
259 alpha: rgba.count > 3 ? rgba[3] : 1)
260 }
261
262 static func path(for shape: BoardShape) -> NSBezierPath {
263 let r = shape.frame
264 switch shape.kind {
265 case .rect, .text, .image:
266 return NSBezierPath(rect: r)
267 case .oval:
268 return NSBezierPath(ovalIn: r)
269 case .triangle:
270 let p = NSBezierPath()
271 p.move(to: NSPoint(x: r.midX, y: r.minY))
272 p.line(to: NSPoint(x: r.maxX, y: r.maxY))
273 p.line(to: NSPoint(x: r.minX, y: r.maxY))
274 p.close()
275 return p
276 case .star:
277 return starPath(in: r, points: max(3, shape.sides), innerRatio: 0.45)
278 case .ngon:
279 return polygonPath(in: r, sides: max(3, shape.sides))
280 }
281 }
282
283 static func polygonPath(in rect: CGRect, sides: Int) -> NSBezierPath {
284 let p = NSBezierPath()
285 let c = NSPoint(x: rect.midX, y: rect.midY)
286 let rx = rect.width / 2, ry = rect.height / 2
287 for i in 0..<sides {
288 let a = -Double.pi / 2 + Double(i) * 2 * .pi / Double(sides)
289 let pt = NSPoint(x: c.x + rx * CGFloat(cos(a)), y: c.y + ry * CGFloat(sin(a)))
290 i == 0 ? p.move(to: pt) : p.line(to: pt)
291 }
292 p.close()
293 return p
294 }
295
296 static func starPath(in rect: CGRect, points: Int, innerRatio: Double) -> NSBezierPath {
297 let p = NSBezierPath()
298 let c = NSPoint(x: rect.midX, y: rect.midY)
299 let rx = rect.width / 2, ry = rect.height / 2
300 for i in 0..<(points * 2) {
301 let a = -Double.pi / 2 + Double(i) * .pi / Double(points)
302 let f = i.isMultiple(of: 2) ? 1.0 : innerRatio
303 let pt = NSPoint(x: c.x + rx * CGFloat(f * cos(a)),
304 y: c.y + ry * CGFloat(f * sin(a)))
305 i == 0 ? p.move(to: pt) : p.line(to: pt)
306 }
307 p.close()
308 return p
309 }
310
311 static func boardFont(size: Double) -> NSFont {
312 NSFont(name: "AT Name Sans Standard", size: size)
313 ?? NSFont(name: "ATNameSansStandard-Regular", size: size)
314 ?? .systemFont(ofSize: size)
315 }
316
317 static func draw(_ shape: BoardShape) {
318 let color = Self.color(shape.color)
319 switch shape.kind {
320 case .text:
321 let attrs: [NSAttributedString.Key: Any] = [
322 .font: boardFont(size: shape.fontSize),
323 .foregroundColor: color,
324 ]
325 (shape.text.isEmpty ? "Text" : shape.text)
326 .draw(in: shape.frame, withAttributes: attrs)
327 case .image:
328 if let path = shape.imagePath, let img = NSImage(contentsOfFile: path) {
329 // Aspect-fit inside the frame.
330 let s = img.size
331 guard s.width > 0, s.height > 0 else { return }
332 let scale = min(shape.frame.width / s.width, shape.frame.height / s.height)
333 let w = s.width * scale, h = s.height * scale
334 let r = NSRect(x: shape.frame.midX - w / 2, y: shape.frame.midY - h / 2,
335 width: w, height: h)
336 // respectFlipped: image refs must not mirror in flipped contexts.
337 img.draw(in: r, from: .zero, operation: .sourceOver,
338 fraction: 1, respectFlipped: true, hints: nil)
339 } else {
340 color.withAlphaComponent(0.25).setFill()
341 NSBezierPath(rect: shape.frame).fill()
342 NSImage(systemSymbolName: "photo", accessibilityDescription: nil)?
343 .draw(in: shape.frame.insetBy(dx: shape.frame.width * 0.3,
344 dy: shape.frame.height * 0.3))
345 }
346 default:
347 let path = Self.path(for: shape)
348 if shape.filled {
349 color.setFill()
350 path.fill()
351 } else {
352 color.setStroke()
353 path.lineWidth = 4
354 path.stroke()
355 }
356 }
357 }
358
359 // MARK: - Board lifecycle helpers
360
361 /// Deep-copy a board (fresh id, copied raster) — used by S on a
362 /// storyboard clip, which splits by duplicating the panel.
363 func duplicate(_ board: Board) -> Board {
364 var copy = board
365 copy.id = UUID()
366 copy.revision = 0
367 duplicateRaster(from: board.id, to: copy.id)
368 return copy
369 }
370
371 // MARK: - Panel copy/paste
372
373 static let pasteboardType = NSPasteboard.PasteboardType("com.sequencer.storyboard-panel")
374
375 private struct PanelTransfer: Codable {
376 var board: Board
377 var rasterPNG: Data?
378 }
379
380 /// Copies both a flattened PNG (for other apps) and full panel metadata
381 /// (shapes + raster) for pasting into another panel.
382 func copyPanel(_ board: Board) {
383 let pb = NSPasteboard.general
384 pb.clearContents()
385 var transfer = PanelTransfer(board: board)
386 transfer.rasterPNG = rasterPNGData(board.id)
387 if let data = try? JSONEncoder().encode(transfer) {
388 pb.setData(data, forType: Self.pasteboardType)
389 }
390 let composite = composite(for: board)
391 if let tiff = composite.tiffRepresentation,
392 let rep = NSBitmapImageRep(data: tiff),
393 let png = rep.representation(using: .png, properties: [:]) {
394 pb.setData(png, forType: .png)
395 }
396 }
397
398 /// Returns a new board built from the pasteboard: full panel metadata
399 /// when present, else any image becomes the raster layer.
400 func panelFromPasteboard(size fallbackSize: CGSize) -> Board? {
401 let pb = NSPasteboard.general
402 if let data = pb.data(forType: Self.pasteboardType),
403 let transfer = try? JSONDecoder().decode(PanelTransfer.self, from: data) {
404 var board = transfer.board
405 board.id = UUID()
406 board.revision = 0
407 if let png = transfer.rasterPNG {
408 setRaster(fromPNG: png, boardId: board.id)
409 }
410 return board
411 }
412 if let img = NSImage(pasteboard: pb) {
413 var board = Board()
414 board.width = Double(fallbackSize.width)
415 board.height = Double(fallbackSize.height)
416 saveRaster(scaled(img, to: board.size), boardId: board.id)
417 return board
418 }
419 return nil
420 }
421
422 private func scaled(_ img: NSImage, to size: CGSize) -> NSImage {
423 NSImage(size: size, flipped: false) { rect in
424 let s = img.size
425 guard s.width > 0, s.height > 0 else { return true }
426 let scale = min(rect.width / s.width, rect.height / s.height)
427 let w = s.width * scale, h = s.height * scale
428 img.draw(in: NSRect(x: rect.midX - w / 2, y: rect.midY - h / 2,
429 width: w, height: h))
430 return true
431 }
432 }
433}
434
435/// AT Name Sans lives on the NAS; copy the needed weights into Application
436/// Support once so text keeps rendering with the share unmounted, then
437/// register from there.
438func registerBoardFonts() {
439 let fm = FileManager.default
440 let fontsDir = FileManager.default.urls(for: .applicationSupportDirectory,
441 in: .userDomainMask)[0]
442 .appendingPathComponent("Sequencer/Fonts", isDirectory: true)
443 try? fm.createDirectory(at: fontsDir, withIntermediateDirectories: true)
444 let source = URL(fileURLWithPath:
445 "/Volumes/clover/Documents/Font/ArrowType/AT Name Sans Standard")
446 for weight in ["Regular", "Medium", "Bold"] {
447 let name = "ATNameSansStandard-\(weight).otf"
448 let local = fontsDir.appendingPathComponent(name)
449 if !fm.fileExists(atPath: local.path) {
450 try? fm.copyItem(at: source.appendingPathComponent(name), to: local)
451 }
452 if fm.fileExists(atPath: local.path) {
453 CTFontManagerRegisterFontsForURL(local as CFURL, .process, nil)
454 }
455 }
456}