Frontend Components: Unit 4 — Image & Media
ShoechooTextView: Drag & Drop
Drag Registration
class ShoechooTextView: NSTextView {
override func awakeFromNib() {
super.awakeFromNib()
registerForDraggedTypes([.fileURL])
}
}
Drag Validation
extension ShoechooTextView {
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
// 1. Read file URLs from pasteboard
// 2. Filter to supported image extensions (png, jpeg, jpg, gif)
// 3. Return .copy if at least one valid image URL, .none otherwise
guard hasValidImageURLs(sender.draggingPasteboard) else {
return .none
}
return .copy
}
override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
// Show insertion cursor at drop point
let point = convert(sender.draggingLocation, from: nil)
let charIndex = characterIndexForInsertion(at: point)
setSelectedRange(NSRange(location: charIndex, length: 0))
return .copy
}
private func hasValidImageURLs(_ pasteboard: NSPasteboard) -> Bool {
guard let urls = pasteboard.readObjects(
forClasses: [NSURL.self],
options: [.urlReadingFileURLsOnly: true]
) as? [URL] else { return false }
let supported: Set<String> = ["png", "jpeg", "jpg", "gif"]
return urls.contains { supported.contains($0.pathExtension.lowercased()) }
}
}
Drop Handling
extension ShoechooTextView {
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
// 1. Extract valid image URLs from pasteboard
guard let urls = sender.draggingPasteboard.readObjects(
forClasses: [NSURL.self],
options: [.urlReadingFileURLsOnly: true]
) as? [URL] else { return false }
let supported: Set<String> = ["png", "jpeg", "jpg", "gif"]
let imageURLs = urls.filter { supported.contains($0.pathExtension.lowercased()) }
guard !imageURLs.isEmpty else { return false }
// 2. Determine drop position
let point = convert(sender.draggingLocation, from: nil)
let dropPosition = characterIndexForInsertion(at: point)
// 3. Forward to delegate (Coordinator → EditorViewModel)
imageDropDelegate?.handleImageDrop(
fileURLs: imageURLs,
at: dropPosition
)
return true
}
}
ShoechooTextView: Clipboard Paste Override
extension ShoechooTextView {
override func paste(_ sender: Any?) {
let pb = NSPasteboard.general
// 1. If pasteboard has text, use standard paste (BR-02.2)
if pb.string(forType: .string) != nil {
super.paste(sender)
return
}
// 2. If pasteboard has image data (no text), intercept for image import
if let image = NSImage(pasteboard: pb) {
let cursorPosition = selectedRange().location
imageDropDelegate?.handleImagePaste(
image: image,
at: cursorPosition
)
return
}
// 3. Fallback: standard paste
super.paste(sender)
}
}
ImageDropDelegate Protocol
Bridge between ShoechooTextView (AppKit) and EditorViewModel (async processing).
protocol ImageDropDelegate: AnyObject {
func handleImageDrop(fileURLs: [URL], at position: Int)
func handleImagePaste(image: NSImage, at position: Int)
}
The WYSIWYGTextView.Coordinator conforms to this protocol and forwards calls to EditorViewModel.
WYSIWYGTextView Coordinator Extension
extension WYSIWYGTextView.Coordinator: ImageDropDelegate {
func handleImageDrop(fileURLs: [URL], at position: Int) {
Task { @MainActor in
await parent.viewModel.handleDroppedImages(fileURLs: fileURLs, at: position)
}
}
func handleImagePaste(image: NSImage, at position: Int) {
Task { @MainActor in
await parent.viewModel.handlePastedImage(image: image, at: position)
}
}
}
EditorViewModel: Image Insertion
State Properties
extension EditorViewModel {
// Pending import (held while save dialog is active)
var pendingImageImport: PendingImageImport?
var showSaveBeforeImageInsert: Bool = false
enum PendingImageImport {
case drop(fileURLs: [URL], position: Int)
case paste(image: NSImage, position: Int)
}
}
Drop Handler
extension EditorViewModel {
func handleDroppedImages(fileURLs: [URL], at position: Int) async {
// 1. Check document readiness
guard let assetsDir = document.assetsDirectoryURL else {
pendingImageImport = .drop(fileURLs: fileURLs, position: position)
showSaveBeforeImageInsert = true
return
}
// 2. Import each image sequentially
var insertionOffset = position
for url in fileURLs {
do {
let result = try await imageService.importDroppedImage(url, to: assetsDir)
insertImage(at: insertionOffset, relativePath: result.relativePath)
insertionOffset += result.relativePath.count + 6 // \n overhead
} catch {
presentError(error)
}
}
}
}
Paste Handler
extension EditorViewModel {
func handlePastedImage(image: NSImage, at position: Int) async {
// 1. Check document readiness
guard let assetsDir = document.assetsDirectoryURL else {
pendingImageImport = .paste(image: image, position: position)
showSaveBeforeImageInsert = true
return
}
// 2. Import pasted image
do {
let result = try await imageService.importPastedImage(
from: NSPasteboard.general,
to: assetsDir
)
insertImage(at: position, relativePath: result.relativePath)
} catch {
presentError(error)
}
}
}
Source Text Insertion
extension EditorViewModel {
func insertImage(at position: Int, relativePath: String) {
// 1. Derive alt text from filename
let filename = URL(string: relativePath)?.lastPathComponent ?? relativePath
let altText = filename
.replacingOccurrences(of: ".\(filename.split(separator: ".").last ?? "")", with: "")
.replacingOccurrences(of: "-", with: " ")
.replacingOccurrences(of: "_", with: " ")
// 2. Build Markdown snippet
let snippet = ")"
// 3. Determine line context
let index = sourceText.index(sourceText.startIndex, offsetBy: min(position, sourceText.count))
let isStartOfLine = index == sourceText.startIndex
|| sourceText[sourceText.index(before: index)] == "\n"
// 4. Insert with appropriate newlines
var insertion = ""
if !isStartOfLine { insertion += "\n" }
insertion += snippet + "\n"
sourceText.insert(contentsOf: insertion, at: index)
// Normal text change flow triggers parse/render
}
}
User Interaction Flows
Flow 1: Drag & Drop Image from Finder
1. User drags PNG/JPEG/GIF from Finder over editor
2. ShoechooTextView.draggingEntered() validates image type → .copy cursor
3. ShoechooTextView.draggingUpdated() shows insertion point as cursor moves
4. User drops image
5. ShoechooTextView.performDragOperation() extracts URLs + drop position
6. Coordinator.handleImageDrop() → EditorViewModel.handleDroppedImages()
7. EditorViewModel checks document.assetsDirectoryURL
8. ImageService.importDroppedImage() → FileService creates dir + copies file
9. EditorViewModel.insertImage() inserts  at drop position
10. Normal parse/render flow displays inline image (Unit 1 BR-02.7)
Flow 2: Paste Image from Clipboard
1. User copies image (e.g., screenshot via Cmd+Shift+4, or copy from Preview)
2. User presses Cmd+V in editor
3. ShoechooTextView.paste() checks pasteboard: no text, has image → intercept
4. Coordinator.handleImagePaste() → EditorViewModel.handlePastedImage()
5. EditorViewModel checks document.assetsDirectoryURL
6. ImageService.importPastedImage() → converts to PNG, generates timestamped name
7. FileService.safeWrite() writes PNG to assets directory
8. EditorViewModel.insertImage() inserts  at cursor
9. Normal parse/render flow displays inline image
Flow 3: Image Drop on Untitled Document
1. User drags image onto unsaved document
2. ShoechooTextView.performDragOperation() fires
3. EditorViewModel.handleDroppedImages() finds assetsDirectoryURL == nil
4. Payload saved to pendingImageImport
5. showSaveBeforeImageInsert = true → SwiftUI presents save dialog
6a. User saves → document gets fileURL → pendingImageImport resumes → normal import flow
6b. User cancels → pendingImageImport discarded, no error shown
Flow 4: Paste Image on Untitled Document
1. User presses Cmd+V with image on clipboard, document is untitled
2. Same flow as Flow 3 but with PendingImageImport.paste variant
Save Prompt (SwiftUI)
// In EditorView
.alert("Save Document", isPresented: $viewModel.showSaveBeforeImageInsert) {
Button("Save") {
Task {
if await viewModel.saveDocument() {
await viewModel.resumePendingImageImport()
}
}
}
Button("Cancel", role: .cancel) {
viewModel.pendingImageImport = nil
}
} message: {
Text("Please save the document before inserting images. Images are stored in a folder next to the document file.")
}
Error Presentation
// In EditorView
.alert("Image Import Error", isPresented: $viewModel.showImageImportError) {
Button("OK", role: .cancel) {}
} message: {
Text(viewModel.imageImportErrorMessage ?? "An unknown error occurred.")
}
Image import errors are caught in the ViewModel handlers and surfaced via showImageImportError / imageImportErrorMessage state properties bound to the SwiftUI alert.