Skip to content

Commit 4ca1fd0

Browse files
committed
ui: todo refs를 전용 markdown 컴포넌트로 렌더링
1 parent 829ba40 commit 4ca1fd0

13 files changed

Lines changed: 197 additions & 86 deletions

DevLog/App/RootView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ struct RootView: View {
4242
case .todoDetail(let todoId):
4343
NavigationStack {
4444
TodoDetailView(viewModel: TodoDetailViewModel(
45-
fetchUseCase: container.resolve(FetchTodoByIdUseCase.self),
46-
fetchTodoIDsByNumbersUseCase: container.resolve(FetchTodoIDsByNumbersUseCase.self),
45+
fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self),
46+
fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self),
4747
upsertUseCase: container.resolve(UpsertTodoUseCase.self),
4848
todoId: todoId,
4949
showEditButton: false

DevLog/Presentation/ViewModel/TodoDetailViewModel.swift

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ final class TodoDetailViewModel: Store {
1212
struct State: Equatable {
1313
var todo: Todo?
1414
var selectedTodoId: TodoIdItem?
15-
var renderedContent: String = ""
15+
var referenceItems: [Int: TodoReferenceItem] = [:]
1616
var isLoading: Bool = false
1717
var showAlert: Bool = false
1818
var showEditor: Bool = false
@@ -28,7 +28,7 @@ final class TodoDetailViewModel: Store {
2828
case setShowInfo(Bool)
2929
case setSelectedTodoId(TodoIdItem?)
3030
case setTodo(Todo)
31-
case setRenderedContent(String)
31+
case setReferenceItems([Int: TodoReferenceItem])
3232
case setLoading(Bool)
3333
case upsertTodo(Todo)
3434
}
@@ -41,21 +41,21 @@ final class TodoDetailViewModel: Store {
4141

4242
private(set) var state: State = .init()
4343
let showEditButton: Bool
44-
private let fetchUseCase: FetchTodoByIdUseCase
45-
private let fetchTodoIDsByNumbersUseCase: FetchTodoIDsByNumbersUseCase
44+
private let fetchTodoUseCase: FetchTodoByIdUseCase
45+
private let fetchReferenceItemsUseCase: FetchReferenceItemsUseCase
4646
private let upsertUseCase: UpsertTodoUseCase
4747
private let todoId: String
4848
private let loadingState = LoadingState()
4949

5050
init(
51-
fetchUseCase: FetchTodoByIdUseCase,
52-
fetchTodoIDsByNumbersUseCase: FetchTodoIDsByNumbersUseCase,
51+
fetchTodoUseCase: FetchTodoByIdUseCase,
52+
fetchReferenceItemsUseCase: FetchReferenceItemsUseCase,
5353
upsertUseCase: UpsertTodoUseCase,
5454
todoId: String,
5555
showEditButton: Bool = true
5656
) {
57-
self.fetchUseCase = fetchUseCase
58-
self.fetchTodoIDsByNumbersUseCase = fetchTodoIDsByNumbersUseCase
57+
self.fetchTodoUseCase = fetchTodoUseCase
58+
self.fetchReferenceItemsUseCase = fetchReferenceItemsUseCase
5959
self.upsertUseCase = upsertUseCase
6060
self.todoId = todoId
6161
self.showEditButton = showEditButton
@@ -78,10 +78,10 @@ final class TodoDetailViewModel: Store {
7878
state.selectedTodoId = todoId
7979
case .setTodo(let todo):
8080
state.todo = todo
81-
state.renderedContent = todo.content
81+
state.referenceItems = [:]
8282
effects = [.resolveMarkdown(todo.content)]
83-
case .setRenderedContent(let content):
84-
state.renderedContent = content
83+
case .setReferenceItems(let items):
84+
state.referenceItems = items
8585
case .setLoading(let value):
8686
state.isLoading = value
8787
case .upsertTodo(let todo):
@@ -99,27 +99,26 @@ final class TodoDetailViewModel: Store {
9999
Task {
100100
do {
101101
defer { endLoading(.immediate) }
102-
let todo = try await fetchUseCase.execute(todoId)
102+
let todo = try await fetchTodoUseCase.execute(todoId)
103103
send(.setTodo(todo))
104104
} catch {
105105
send(.setAlert(true))
106106
}
107107
}
108108
case .resolveMarkdown(let content):
109109
Task {
110-
var renderedContent = content
111110
let numbers = content.todoReferenceNumbers
111+
var referenceItems = [Int: TodoReferenceItem]()
112112

113113
if !numbers.isEmpty {
114114
do {
115-
let todoIDsByNumber = try await fetchTodoIDsByNumbersUseCase.execute(numbers)
116-
renderedContent = content.replacingTodoReferenceLines(using: todoIDsByNumber)
115+
referenceItems = try await fetchReferenceItemsUseCase.execute(numbers)
117116
} catch {
118-
renderedContent = content
117+
referenceItems = [:]
119118
}
120119
}
121120

122-
send(.setRenderedContent(renderedContent))
121+
send(.setReferenceItems(referenceItems))
123122
}
124123
case .upsertTodo(let todo):
125124
beginLoading(.delayed)

DevLog/Presentation/ViewModel/TodoEditorViewModel.swift

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ final class TodoEditorViewModel: Store {
5050
var selectedTodoId: TodoIdItem?
5151
var title: String = ""
5252
var content: String = ""
53-
var renderedContent: String = ""
53+
var referenceItems: [Int: TodoReferenceItem] = [:]
5454
var dueDate: Date?
5555
var showInfo: Bool = false
5656
var tags: OrderedSet<String> = []
@@ -80,7 +80,7 @@ final class TodoEditorViewModel: Store {
8080
case setTabViewTag(Tag)
8181
case setTagText(String)
8282
case setTitle(String)
83-
case setRenderedContent(String)
83+
case setReferenceItems([Int: TodoReferenceItem])
8484
}
8585

8686
enum SideEffect {
@@ -89,7 +89,7 @@ final class TodoEditorViewModel: Store {
8989

9090
private(set) var state = State()
9191
private let calendar = Calendar.current
92-
private let fetchTodoIDsByNumbersUseCase: FetchTodoIDsByNumbersUseCase
92+
private let fetchReferenceItemsUseCase: FetchReferenceItemsUseCase
9393
private let id: String
9494
private let isCompleted: Bool
9595
private let isChecked: Bool
@@ -117,9 +117,9 @@ final class TodoEditorViewModel: Store {
117117
// 새로운 Todo 생성용 생성자
118118
init(
119119
kind: TodoKind,
120-
fetchTodoIDsByNumbersUseCase: FetchTodoIDsByNumbersUseCase
120+
fetchReferenceItemsUseCase: FetchReferenceItemsUseCase
121121
) {
122-
self.fetchTodoIDsByNumbersUseCase = fetchTodoIDsByNumbersUseCase
122+
self.fetchReferenceItemsUseCase = fetchReferenceItemsUseCase
123123
self.id = UUID().uuidString
124124
self.isCompleted = false
125125
self.isChecked = false
@@ -132,9 +132,9 @@ final class TodoEditorViewModel: Store {
132132
// 기존 Todo 편집용 생성자
133133
init(
134134
todo: Todo,
135-
fetchTodoIDsByNumbersUseCase: FetchTodoIDsByNumbersUseCase
135+
fetchReferenceItemsUseCase: FetchReferenceItemsUseCase
136136
) {
137-
self.fetchTodoIDsByNumbersUseCase = fetchTodoIDsByNumbersUseCase
137+
self.fetchReferenceItemsUseCase = fetchReferenceItemsUseCase
138138
self.id = todo.id
139139
self.isCompleted = todo.isCompleted
140140
self.isChecked = todo.isChecked
@@ -146,7 +146,6 @@ final class TodoEditorViewModel: Store {
146146
state.isPinned = todo.isPinned
147147
state.title = todo.title
148148
state.content = todo.content
149-
state.renderedContent = todo.content
150149
state.dueDate = todo.dueDate
151150
state.tags = OrderedSet(todo.tags)
152151
state.kind = todo.kind
@@ -194,8 +193,8 @@ final class TodoEditorViewModel: Store {
194193
if tag == .preview {
195194
effects = [.resolveMarkdown(state.content)]
196195
}
197-
case .setRenderedContent(let content):
198-
state.renderedContent = content
196+
case .setReferenceItems(let items):
197+
state.referenceItems = items
199198
}
200199

201200
if self.state != state { self.state = state }
@@ -206,19 +205,18 @@ final class TodoEditorViewModel: Store {
206205
switch effect {
207206
case .resolveMarkdown(let content):
208207
Task {
209-
var renderedContent = content
210208
let numbers = content.todoReferenceNumbers
209+
var referenceItems = [Int: TodoReferenceItem]()
211210

212211
if !numbers.isEmpty {
213212
do {
214-
let todoIDsByNumber = try await fetchTodoIDsByNumbersUseCase.execute(numbers)
215-
renderedContent = content.replacingTodoReferenceLines(using: todoIDsByNumber)
213+
referenceItems = try await fetchReferenceItemsUseCase.execute(numbers)
216214
} catch {
217-
renderedContent = content
215+
referenceItems = [:]
218216
}
219217
}
220218

221-
send(.setRenderedContent(renderedContent))
219+
send(.setReferenceItems(referenceItems))
222220
}
223221
}
224222
}

DevLog/UI/Common/TodoDetailContentView.swift

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import MarkdownUI
1010

1111
struct TodoDetailContentView: View {
1212
let title: String
13-
let renderedContent: String
13+
let content: String
14+
let referenceItems: [Int: TodoReferenceItem]
1415
var number: Int?
1516
var activityLabel: String?
1617
var onOpenTodoID: ((String) -> Void)?
@@ -47,22 +48,11 @@ struct TodoDetailContentView: View {
4748
.font(.title3.bold())
4849
.padding(.horizontal)
4950
Divider()
50-
Markdown(renderedContent)
51-
.environment(\.openURL, OpenURLAction { url in
52-
guard
53-
url.scheme == "devlog",
54-
url.host == "todo"
55-
else {
56-
return .systemAction
57-
}
58-
59-
let todoID = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
60-
if !todoID.isEmpty {
61-
onOpenTodoID?(todoID)
62-
}
63-
64-
return .handled
65-
})
51+
TodoMarkdownContentView(
52+
content: content,
53+
referenceItems: referenceItems,
54+
onOpenTodoID: onOpenTodoID
55+
)
6656
.padding(.horizontal)
6757
}
6858
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
//
2+
// TodoMarkdownContentView.swift
3+
// DevLog
4+
//
5+
// Created by opfic on 3/25/26.
6+
//
7+
8+
import MarkdownUI
9+
import SwiftUI
10+
11+
private enum TodoMarkdownSection: Equatable {
12+
case markdown(String)
13+
case reference(Int)
14+
}
15+
16+
struct TodoMarkdownContentView: View {
17+
let content: String
18+
let referenceItems: [Int: TodoReferenceItem]
19+
var onOpenTodoID: ((String) -> Void)?
20+
21+
var body: some View {
22+
LazyVStack(alignment: .leading, spacing: 0) {
23+
let sections = makeSections(from: content)
24+
ForEach(Array(zip(sections.indices, sections)), id: \.0) { _, section in
25+
switch section {
26+
case .markdown(let markdown):
27+
if !markdown.isEmpty {
28+
Markdown(markdown)
29+
}
30+
case .reference(let number):
31+
if let item = referenceItems[number] {
32+
TodoReferenceRow(
33+
item: item,
34+
number: number,
35+
onOpenTodoID: onOpenTodoID
36+
)
37+
} else {
38+
Markdown("- refs #\(number)")
39+
}
40+
}
41+
}
42+
}
43+
}
44+
45+
private func makeSections(from content: String) -> [TodoMarkdownSection] {
46+
let lines = content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
47+
var sections = [TodoMarkdownSection]()
48+
var markdownBuffer = [String]()
49+
50+
func flushMarkdownBuffer() {
51+
guard !markdownBuffer.isEmpty else { return }
52+
sections.append(.markdown(markdownBuffer.joined(separator: "\n")))
53+
markdownBuffer.removeAll(keepingCapacity: true)
54+
}
55+
56+
for line in lines {
57+
if let number = todoReferenceLineNumber(from: line) {
58+
flushMarkdownBuffer()
59+
sections.append(.reference(number))
60+
} else {
61+
markdownBuffer.append(line)
62+
}
63+
}
64+
65+
flushMarkdownBuffer()
66+
return sections
67+
}
68+
69+
private func todoReferenceLineNumber(from line: String) -> Int? {
70+
guard let expression = try? NSRegularExpression(pattern: #"^([ \t]*)-[ \t]+refs[ \t]+#(\d+)[ \t]*$"#) else {
71+
return nil
72+
}
73+
74+
let range = NSRange(line.startIndex..., in: line)
75+
guard
76+
let match = expression.firstMatch(in: line, options: [], range: range),
77+
match.range == range,
78+
let numberRange = Range(match.range(at: 2), in: line),
79+
let number = Int(line[numberRange])
80+
else {
81+
return nil
82+
}
83+
84+
return number
85+
}
86+
}
87+
88+
private struct TodoReferenceRow: View {
89+
let item: TodoReferenceItem
90+
let number: Int
91+
var onOpenTodoID: ((String) -> Void)?
92+
93+
var body: some View {
94+
HStack(alignment: .center, spacing: 8) {
95+
Markdown("- refs")
96+
Button {
97+
onOpenTodoID?(item.id)
98+
} label: {
99+
HStack(alignment: .center, spacing: 8) {
100+
RoundedRectangle(cornerRadius: 5)
101+
.fill(item.kind.color)
102+
.frame(width: 18, height: 18)
103+
.overlay {
104+
Image(systemName: item.kind.symbolName)
105+
.font(.caption2.weight(.bold))
106+
.foregroundStyle(.white)
107+
}
108+
109+
HStack(alignment: .firstTextBaseline, spacing: 4) {
110+
Text(item.title)
111+
.foregroundStyle(.blue)
112+
Text("#\(number)")
113+
.foregroundStyle(.secondary)
114+
.fixedSize(horizontal: true, vertical: false)
115+
116+
}
117+
.lineLimit(1)
118+
.overlay(alignment: .bottomLeading) {
119+
Rectangle()
120+
.fill(Color.blue)
121+
.frame(height: 1)
122+
.offset(y: 1)
123+
}
124+
125+
Spacer()
126+
}
127+
.frame(maxWidth: .infinity, alignment: .leading)
128+
}
129+
.buttonStyle(.plain)
130+
}
131+
.padding(.vertical, 2)
132+
}
133+
}

DevLog/UI/Home/HomeView.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ struct HomeView: View {
3636
.environment(router)
3737
case .detail(let todoId):
3838
TodoDetailView(viewModel: TodoDetailViewModel(
39-
fetchUseCase: container.resolve(FetchTodoByIdUseCase.self),
40-
fetchTodoIDsByNumbersUseCase: container.resolve(FetchTodoIDsByNumbersUseCase.self),
39+
fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self),
40+
fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self),
4141
upsertUseCase: container.resolve(UpsertTodoUseCase.self),
4242
todoId: todoId
4343
))
@@ -83,7 +83,7 @@ struct HomeView: View {
8383
TodoEditorView(
8484
viewModel: TodoEditorViewModel(
8585
kind: selectedKind,
86-
fetchTodoIDsByNumbersUseCase: container.resolve(FetchTodoIDsByNumbersUseCase.self)
86+
fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self)
8787
),
8888
onSubmit: { viewModel.send(.addTodo($0)) }
8989
)

0 commit comments

Comments
 (0)