diff --git a/Modules/DesignSystem/Derived/Sources/TuistAssets+DesignSystem.swift b/Modules/DesignSystem/Derived/Sources/TuistAssets+DesignSystem.swift index b8646b1..bcd2aa6 100644 --- a/Modules/DesignSystem/Derived/Sources/TuistAssets+DesignSystem.swift +++ b/Modules/DesignSystem/Derived/Sources/TuistAssets+DesignSystem.swift @@ -4,22 +4,32 @@ // swiftformat:disable all // Generated using tuist — https://github.com/tuist/tuist + + #if os(macOS) - import AppKit -#elseif os(iOS) - import UIKit -#elseif os(tvOS) || os(watchOS) - import UIKit +#if hasFeature(InternalImportsByDefault) +public import AppKit +#else +import AppKit +#endif +#else +#if hasFeature(InternalImportsByDefault) +public import UIKit +#else +import UIKit #endif -#if canImport(SwiftUI) - import SwiftUI #endif -// swiftlint:disable superfluous_disable_command file_length implicit_return +#if canImport(SwiftUI) +#if hasFeature(InternalImportsByDefault) +public import SwiftUI +#else +import SwiftUI +#endif +#endif // MARK: - Asset Catalogs -// swiftlint:disable identifier_name line_length nesting type_body_length type_name public enum DesignSystemAsset: Sendable { public static let accentColor = DesignSystemColors(name: "AccentColor") public static let allcheck = DesignSystemImages(name: "allcheck") @@ -36,6 +46,7 @@ public enum DesignSystemAsset: Sendable { public static let logo = DesignSystemImages(name: "logo") public static let mailbox = DesignSystemImages(name: "mailbox") public static let nodata = DesignSystemImages(name: "nodata") + public static let nohighlight = DesignSystemImages(name: "nohighlight") public static let nologin = DesignSystemImages(name: "nologin") public static let noprofile = DesignSystemImages(name: "noprofile") public static let nosubscribe = DesignSystemImages(name: "nosubscribe") @@ -103,7 +114,6 @@ public enum DesignSystemAsset: Sendable { public static let lineCloseEye = DesignSystemImages(name: "_Line Close Eye") public static let bookmarked = DesignSystemImages(name: "bookmarked") } -// swiftlint:enable identifier_name line_length nesting type_body_length type_name // MARK: - Implementation Details @@ -212,5 +222,5 @@ public extension SwiftUI.Image { } #endif -// swiftlint:enable all // swiftformat:enable all +// swiftlint:enable all diff --git a/Modules/DesignSystem/Resources/Assets.xcassets/nohighlight.imageset/Contents.json b/Modules/DesignSystem/Resources/Assets.xcassets/nohighlight.imageset/Contents.json new file mode 100644 index 0000000..537ad16 --- /dev/null +++ b/Modules/DesignSystem/Resources/Assets.xcassets/nohighlight.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "nohighlight.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Modules/DesignSystem/Resources/Assets.xcassets/nohighlight.imageset/nohighlight.pdf b/Modules/DesignSystem/Resources/Assets.xcassets/nohighlight.imageset/nohighlight.pdf new file mode 100644 index 0000000..befccf0 Binary files /dev/null and b/Modules/DesignSystem/Resources/Assets.xcassets/nohighlight.imageset/nohighlight.pdf differ diff --git a/Modules/DesignSystem/Resources/Assets.xcassets/trashbin.imageset/Contents.json b/Modules/DesignSystem/Resources/Assets.xcassets/trashbin.imageset/Contents.json new file mode 100644 index 0000000..489072a --- /dev/null +++ b/Modules/DesignSystem/Resources/Assets.xcassets/trashbin.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Icon Button.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Modules/DesignSystem/Resources/Assets.xcassets/trashbin.imageset/Icon Button.pdf b/Modules/DesignSystem/Resources/Assets.xcassets/trashbin.imageset/Icon Button.pdf new file mode 100644 index 0000000..0e12fd9 Binary files /dev/null and b/Modules/DesignSystem/Resources/Assets.xcassets/trashbin.imageset/Icon Button.pdf differ diff --git a/Modules/Features/Detail/Sources/Article/ArticleDetailView.swift b/Modules/Features/Detail/Sources/Article/ArticleDetailView.swift index 5384c60..4dbef62 100644 --- a/Modules/Features/Detail/Sources/Article/ArticleDetailView.swift +++ b/Modules/Features/Detail/Sources/Article/ArticleDetailView.swift @@ -20,7 +20,7 @@ public struct ArticleDetailView: View { @State private var bookmarkToastMessage: String = "" // 폰트 크기 조절 - @State private var fontSize: CGFloat = UserDefaults.standard.object(forKey: "articleFontSize") as? CGFloat ?? 15.0 + @State private var fontSize: CGFloat = UserDefaults.standard.object(forKey: "articleFontSize") as? CGFloat ?? 20.0 @State private var showFontSizeControl: Bool = false // WebView 참조 (JS 실행용) @@ -30,6 +30,9 @@ public struct ArticleDetailView: View { @State private var showHighlightList: Bool = false @State private var pendingHighlightRemovals: [String] = [] + // 스크롤 최상단 버튼 + @State private var showScrollToTop: Bool = false + public init(viewModel: ArticleDetailViewModel) { _viewModel = StateObject(wrappedValue: viewModel) } @@ -48,12 +51,44 @@ public struct ArticleDetailView: View { fontSize: $fontSize, webViewRef: $webViewRef, selectedText: $viewModel.selectedText, + showScrollToTop: $showScrollToTop, onSaveHighlight: { type in viewModel.saveHighlight(type: type) + }, + onHighlightTypeChanged: { text, newType in + viewModel.changeHighlightType(text: text, newType: newType) + }, + onHighlightDeleted: { text in + viewModel.deleteHighlightByText(text: text) } ) .ignoresSafeArea(edges: .bottom) } + + // 스크롤 최상단 버튼 (C) + if showScrollToTop { + VStack { + Spacer() + HStack { + Spacer() + Button { + webViewRef?.scrollView.setContentOffset(.zero, animated: true) + } label: { + Image(systemName: "arrow.up") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.black) + .frame(width: 44, height: 44) + .background( + Circle() + .fill(.white) + .shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4) + ) + } + .padding(.trailing, 20) + .padding(.bottom, 24) + } + } + } } .navigationBarBackButtonHidden(true) .navigationBarTitleDisplayMode(.inline) @@ -183,64 +218,16 @@ public struct ArticleDetailView: View { } } -// MARK: - Custom WKWebView with Edit Menu +// MARK: - Custom WKWebView (네이티브 메뉴 억제, JS 팔레트 사용) class HighlightableWebView: WKWebView { - var onHighlight: ((String) -> Void)? - var onGetSelectedText: (() -> String?)? - override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { - // 기본 액션 허용 - if super.canPerformAction(action, withSender: sender) { - return true - } - // 커스텀 하이라이트 액션 허용 - if action == #selector(highlightYellow) || - action == #selector(highlightPink) || - action == #selector(highlightGreen) || - action == #selector(highlightBlue) || - action == #selector(highlightUnderline) { - return true - } + // 네이티브 에디트 메뉴 억제 → JS 커스텀 팔레트 사용 return false } override func buildMenu(with builder: any UIMenuBuilder) { - super.buildMenu(with: builder) - - let highlightMenu = UIMenu( - title: "형광펜", - image: UIImage(systemName: "highlighter"), - children: [ - UIAction(title: "노랑", image: UIImage(systemName: "circle.fill")?.withTintColor(.systemYellow, renderingMode: .alwaysOriginal)) { [weak self] _ in - self?.onHighlight?("yellow") - }, - UIAction(title: "분홍", image: UIImage(systemName: "circle.fill")?.withTintColor(.systemPink, renderingMode: .alwaysOriginal)) { [weak self] _ in - self?.onHighlight?("pink") - }, - UIAction(title: "초록", image: UIImage(systemName: "circle.fill")?.withTintColor(.systemGreen, renderingMode: .alwaysOriginal)) { [weak self] _ in - self?.onHighlight?("green") - }, - UIAction(title: "파랑", image: UIImage(systemName: "circle.fill")?.withTintColor(.systemBlue, renderingMode: .alwaysOriginal)) { [weak self] _ in - self?.onHighlight?("blue") - } - ] - ) - - let underlineAction = UIAction( - title: "밑줄", - image: UIImage(systemName: "underline") - ) { [weak self] _ in - self?.onHighlight?("underline") - } - - builder.insertChild(UIMenu(title: "", options: .displayInline, children: [highlightMenu, underlineAction]), atStartOfMenu: .standardEdit) + // JS 팔레트를 사용하므로 네이티브 메뉴 비활성화 } - - @objc func highlightYellow() { onHighlight?("yellow") } - @objc func highlightPink() { onHighlight?("pink") } - @objc func highlightGreen() { onHighlight?("green") } - @objc func highlightBlue() { onHighlight?("blue") } - @objc func highlightUnderline() { onHighlight?("underline") } } // MARK: - Full WebView (헤더 포함, 스크롤 활성화) @@ -254,7 +241,10 @@ struct FullWebView: UIViewRepresentable { @Binding var fontSize: CGFloat @Binding var webViewRef: WKWebView? @Binding var selectedText: String + @Binding var showScrollToTop: Bool var onSaveHighlight: ((String) -> Void)? + var onHighlightTypeChanged: ((String, String) -> Void)? + var onHighlightDeleted: ((String) -> Void)? func makeCoordinator() -> Coordinator { Coordinator(self) @@ -266,6 +256,8 @@ struct FullWebView: UIViewRepresentable { config.userContentController.add(context.coordinator, name: "textSelected") config.userContentController.add(context.coordinator, name: "getSelectedText") config.userContentController.add(context.coordinator, name: "consoleLog") + config.userContentController.add(context.coordinator, name: "highlightTypeChanged") + config.userContentController.add(context.coordinator, name: "highlightDeleted") let webView = HighlightableWebView(frame: .zero, configuration: config) webView.uiDelegate = context.coordinator @@ -276,17 +268,12 @@ struct FullWebView: UIViewRepresentable { webView.scrollView.bounces = true webView.scrollView.showsVerticalScrollIndicator = true webView.scrollView.contentInsetAdjustmentBehavior = .never + webView.scrollView.delegate = context.coordinator webView.isOpaque = false webView.backgroundColor = .white webView.allowsBackForwardNavigationGestures = false - // 하이라이트 콜백 설정 - let coordinator = context.coordinator - webView.onHighlight = { color in - coordinator.applyHighlight(color: color, in: webView) - } - DispatchQueue.main.async { self.webViewRef = webView } @@ -295,6 +282,8 @@ struct FullWebView: UIViewRepresentable { } func updateUIView(_ uiView: HighlightableWebView, context: Context) { + context.coordinator.parent = self + // 콘텐츠 변경 시에만 재로드 (하이라이트 변경은 JS로 처리) let contentKey = "\(htmlContent)-\(headerImageUrl)" @@ -303,8 +292,8 @@ struct FullWebView: UIViewRepresentable { context.coordinator.lastFontSize = fontSize context.coordinator.lastHighlightCount = savedHighlights.count - let highlightsJSON = savedHighlights.map { h in - ["text": h.selectedText, "type": h.highlightType] + let highlightsJSON = savedHighlights.map { highlight in + ["text": highlight.selectedText, "type": highlight.highlightType] } let htmlBuilder = ArticleHTMLBuilder( @@ -323,7 +312,7 @@ struct FullWebView: UIViewRepresentable { } } - class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler { + class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, UIScrollViewDelegate { var parent: FullWebView var lastContentKey: String? var lastFontSize: CGFloat? @@ -333,6 +322,19 @@ struct FullWebView: UIViewRepresentable { self.parent = parent } + // MARK: - Scroll Tracking + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let shouldShow = scrollView.contentOffset.y > 200 + if parent.showScrollToTop != shouldShow { + DispatchQueue.main.async { + self.parent.showScrollToTop = shouldShow + } + } + } + + // MARK: - WKScriptMessageHandler + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { // JavaScript console.log 출력 if message.name == "consoleLog" { @@ -341,7 +343,9 @@ struct FullWebView: UIViewRepresentable { } guard let body = message.body as? [String: Any] else { return } - if message.name == "textSelected" { + + switch message.name { + case "textSelected": let hasSelection = body["hasSelection"] as? Bool ?? false let highlightApplied = body["highlightApplied"] as? Bool ?? false let highlightColor = body["highlightColor"] as? String @@ -350,18 +354,27 @@ struct FullWebView: UIViewRepresentable { if hasSelection { self.parent.selectedText = (body["text"] as? String) ?? "" } - - // 하이라이트가 적용된 경우 저장 if highlightApplied, let color = highlightColor { self.parent.onSaveHighlight?(color) } } - } - } - func applyHighlight(color: String, in webView: WKWebView) { - let script = "applyHighlight('\(color)');" - webView.evaluateJavaScript(script, completionHandler: nil) + case "highlightTypeChanged": + let text = body["text"] as? String ?? "" + let newType = body["newType"] as? String ?? "" + DispatchQueue.main.async { + self.parent.onHighlightTypeChanged?(text, newType) + } + + case "highlightDeleted": + let text = body["text"] as? String ?? "" + DispatchQueue.main.async { + self.parent.onHighlightDeleted?(text) + } + + default: + break + } } func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, @@ -376,11 +389,12 @@ struct FullWebView: UIViewRepresentable { // MARK: - Font Size Control Component struct FontSizeControlView: View { - @Environment(\.dismiss) private var dismiss + @Environment(\.dismiss) + private var dismiss @Binding var fontSize: CGFloat private let minFontSize: CGFloat = 15 - private let maxFontSize: CGFloat = 30 + private let maxFontSize: CGFloat = 25 var body: some View { VStack(spacing: 0) { @@ -408,7 +422,7 @@ struct FontSizeControlView: View { // 컨트롤 영역 VStack(spacing: 20) { // 현재 폰트 크기 표시 - Text("\(Int(fontSize))pt") + Text("\(Int(fontSize)) pt") .font(.system(size: 32, weight: .bold)) .foregroundColor(.black) .padding(.top, 24) @@ -422,9 +436,17 @@ struct FontSizeControlView: View { saveFontSize() } }) { - Image(systemName: "minus.circle.fill") - .font(.system(size: 36)) + Image(systemName: "minus") + .font(.system(size: 16, weight: .medium)) .foregroundColor(fontSize <= minFontSize ? .gray.opacity(0.3) : Color.primaryNormal) + .frame(width: 36, height: 36) + .overlay( + Circle().stroke( + fontSize <= minFontSize + ? Color.gray.opacity(0.3) : Color.primaryNormal, + lineWidth: 1.5 + ) + ) } .disabled(fontSize <= minFontSize) @@ -448,9 +470,17 @@ struct FontSizeControlView: View { saveFontSize() } }) { - Image(systemName: "plus.circle.fill") - .font(.system(size: 36)) + Image(systemName: "plus") + .font(.system(size: 16, weight: .medium)) .foregroundColor(fontSize >= maxFontSize ? .gray.opacity(0.3) : Color.primaryNormal) + .frame(width: 36, height: 36) + .overlay( + Circle().stroke( + fontSize >= maxFontSize + ? Color.gray.opacity(0.3) : Color.primaryNormal, + lineWidth: 1.5 + ) + ) } .disabled(fontSize >= maxFontSize) } @@ -486,141 +516,3 @@ struct FontSizeControlView: View { UserDefaults.standard.set(fontSize, forKey: "articleFontSize") } } - -// MARK: - Highlight List View -struct HighlightListView: View { - @Environment(\.dismiss) private var dismiss - - let highlights: [ArticleHighlight] - let onSelectHighlight: (ArticleHighlight) -> Void - var onDeleteHighlight: ((ArticleHighlight) -> Void)? - - var body: some View { - NavigationView { - Group { - if highlights.isEmpty { - VStack(spacing: 16) { - Image(systemName: "highlighter") - .font(.system(size: 48)) - .foregroundColor(.gray.opacity(0.5)) - - Text("저장된 하이라이트가 없습니다") - .font(.hanSansNeo(16, .medium)) - .foregroundColor(.gray) - - Text("텍스트를 길게 눌러 형광펜이나\n밑줄을 추가해보세요") - .font(.hanSansNeo(14, .regular)) - .foregroundColor(.gray.opacity(0.7)) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - List { - ForEach(highlights, id: \.id) { highlight in - HighlightRowView(highlight: highlight) - .contentShape(Rectangle()) - .onTapGesture { - onSelectHighlight(highlight) - } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(role: .destructive) { - onDeleteHighlight?(highlight) - } label: { - Label("삭제", systemImage: "trash") - } - } - } - } - .listStyle(.plain) - } - } - .navigationTitle("내 하이라이트") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("완료") { - dismiss() - } - } - } - } - } -} - -// MARK: - Highlight Row View -struct HighlightRowView: View { - let highlight: ArticleHighlight - - private var highlightColor: Color { - switch highlight.highlightType { - case "yellow": return Color(red: 1.0, green: 0.96, blue: 0.62) - case "pink": return Color(red: 0.97, green: 0.73, blue: 0.85) - case "green": return Color(red: 0.78, green: 0.90, blue: 0.79) - case "blue": return Color(red: 0.73, green: 0.87, blue: 0.98) - case "underline": return .clear - default: return .clear - } - } - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - if highlight.highlightType == "underline" { - Image(systemName: "underline") - .font(.system(size: 14)) - .foregroundColor(.gray) - } else { - Circle() - .fill(highlightColor) - .frame(width: 16, height: 16) - .overlay( - Circle() - .stroke(Color.gray.opacity(0.3), lineWidth: 1) - ) - } - - Text(formatDate(highlight.createdAt)) - .font(.hanSansNeo(12, .regular)) - .foregroundColor(.gray) - - Spacer() - - Image(systemName: "chevron.right") - .font(.system(size: 12)) - .foregroundColor(.gray.opacity(0.5)) - } - - Text(highlight.selectedText) - .font(.hanSansNeo(14, .regular)) - .foregroundColor(.black) - .lineLimit(3) - .padding(.vertical, 8) - .padding(.horizontal, 12) - .background( - Group { - if highlight.highlightType == "underline" { - Rectangle() - .fill(Color.clear) - .overlay( - Rectangle() - .frame(height: 1) - .foregroundColor(.gray), - alignment: .bottom - ) - } else { - RoundedRectangle(cornerRadius: 4) - .fill(highlightColor.opacity(0.5)) - } - } - ) - } - .padding(.vertical, 4) - } - - private func formatDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "ko_KR") - formatter.dateFormat = "M월 d일 HH:mm" - return formatter.string(from: date) - } -} diff --git a/Modules/Features/Detail/Sources/Article/ArticleDetailViewModel.swift b/Modules/Features/Detail/Sources/Article/ArticleDetailViewModel.swift index 12ec2fa..16e880b 100644 --- a/Modules/Features/Detail/Sources/Article/ArticleDetailViewModel.swift +++ b/Modules/Features/Detail/Sources/Article/ArticleDetailViewModel.swift @@ -108,10 +108,30 @@ public final class ArticleDetailViewModel: ObservableObject { highlights = highlightStorage.fetchHighlights(for: actualArticleId) } + /// 하이라이트 타입 변경 (JS 에디트 메뉴에서 호출) + func changeHighlightType(text: String, newType: String) { + guard let detail = detail else { return } + let articleId = String(detail.articleId) + if let highlight = highlightStorage.fetchHighlight(for: articleId, text: text) { + highlightStorage.updateHighlightType(highlight, newType: newType) + loadHighlights() + } + } + + /// 텍스트 기준으로 하이라이트 삭제 (JS 에디트 메뉴에서 호출) + func deleteHighlightByText(text: String) { + guard let detail = detail else { return } + let articleId = String(detail.articleId) + if let highlight = highlightStorage.fetchHighlight(for: articleId, text: text) { + highlightStorage.delete(highlight) + loadHighlights() + } + } + /// 하이라이트 JSON 배열 (WebView용) func highlightsJSON() -> [[String: String]] { - highlights.map { h in - ["text": h.selectedText, "type": h.highlightType] + highlights.map { highlight in + ["text": highlight.selectedText, "type": highlight.highlightType] } } } diff --git a/Modules/Features/Detail/Sources/Article/ArticleHTMLBuilder.swift b/Modules/Features/Detail/Sources/Article/ArticleHTMLBuilder.swift index 2907564..813f885 100644 --- a/Modules/Features/Detail/Sources/Article/ArticleHTMLBuilder.swift +++ b/Modules/Features/Detail/Sources/Article/ArticleHTMLBuilder.swift @@ -47,6 +47,7 @@ struct ArticleHTMLBuilder { adjustFontSize(\(fontSize)); applySavedHighlights(); setupTextSelection(); + setupHighlightClickHandlers(); }); \(ArticleHighlightJS.coreScript) @@ -128,10 +129,97 @@ struct ArticleHTMLBuilder { height: auto !important; display: block !important; } - .highlight-yellow { background-color: #FFF59D !important; } - .highlight-pink { background-color: #F8BBD9 !important; } - .highlight-green { background-color: #C8E6C9 !important; } - .highlight-blue { background-color: #BBDEFB !important; } - .highlight-underline { text-decoration: underline !important; text-decoration-color: #333 !important; } + .highlight-yellow { background-color: #FBE96C !important; } + .highlight-orange { background-color: #FFC194 !important; } + .highlight-pink { background-color: #F1B2C7 !important; } + .highlight-green { background-color: #D7EDA1 !important; } + .highlight-blue { background-color: #95D5EC !important; } + .highlight-underline { + text-decoration: underline !important; + text-decoration-color: #EF4444 !important; + text-decoration-thickness: 2px !important; + } + .highlight-focused { + outline: 2px solid #2866D3 !important; + outline-offset: 1px; + border-radius: 2px; + } + /* 통합 팔레트 (선택 팔레트 + 하이라이트 에디트 메뉴) */ + .hl-palette { + position: fixed; + display: flex; + align-items: center; + gap: 6px; + height: 34px; + padding: 0 10px; + background: rgba(55, 55, 55, 0.96); + border-radius: 17px; + z-index: 99999; + transform: translateX(-50%); + box-shadow: 0 3px 10px rgba(0,0,0,0.35); + -webkit-user-select: none; + user-select: none; + } + .hl-palette .hl-dot { + width: 20px; + height: 20px; + border-radius: 50%; + border: none; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + flex-shrink: 0; + } + .hl-palette .hl-dot.selected { + box-shadow: 0 0 0 2px #2866D3; + } + .hl-palette .hl-dot.yellow { background: #FBE96C; } + .hl-palette .hl-dot.orange { background: #FFC194; } + .hl-palette .hl-dot.pink { background: #F1B2C7; } + .hl-palette .hl-dot.green { background: #D7EDA1; } + .hl-palette .hl-dot.blue { background: #95D5EC; } + .hl-palette .hl-sep { + width: 1px; + height: 16px; + background: rgba(255, 255, 255, 0.25); + flex-shrink: 0; + } + .hl-palette .hl-underline { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 12px; + font-weight: 600; + line-height: 1; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + text-decoration: underline; + text-decoration-color: #EF4444; + text-decoration-thickness: 2px; + text-underline-offset: 2px; + flex-shrink: 0; + border: 2px solid transparent; + border-radius: 4px; + box-sizing: border-box; + } + .hl-palette .hl-underline.selected { + border-color: #2866D3; + } + .hl-palette .hl-trash { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + flex-shrink: 0; + } + .hl-palette .hl-trash svg { + width: 14px; + height: 14px; + } """ } diff --git a/Modules/Features/Detail/Sources/Article/ArticleHighlightJS.swift b/Modules/Features/Detail/Sources/Article/ArticleHighlightJS.swift index c48e4bc..194fde5 100644 --- a/Modules/Features/Detail/Sources/Article/ArticleHighlightJS.swift +++ b/Modules/Features/Detail/Sources/Article/ArticleHighlightJS.swift @@ -5,13 +5,21 @@ // Created by 권민재 on 2/14/26. // +// swiftlint:disable file_length import Foundation /// JavaScript 코드를 관리하는 구조체 enum ArticleHighlightJS { - // MARK: - Core Highlight Functions + // MARK: - Combined Script - static let coreScript = """ + static var coreScript: String { + highlightScript + "\n" + selectionScript + } +} + +// MARK: - Core Highlight Functions +extension ArticleHighlightJS { + static let highlightScript = """ let originalFontSizes = new Map(); let savedRange = null; @@ -245,6 +253,12 @@ enum ArticleHighlightJS { parent.replaceChild(fragment, textNode); } + """ +} + +// MARK: - Selection & Edit Menu Functions +extension ArticleHighlightJS { + static let selectionScript = """ function setupTextSelection() { document.addEventListener('selectionchange', function() { const selection = window.getSelection(); @@ -258,8 +272,13 @@ enum ArticleHighlightJS { } }); - document.addEventListener('touchend', function() { + document.addEventListener('touchend', function(e) { + if (e.target.closest('.hl-palette')) return; + setTimeout(function() { + // 에디트 메뉴가 이미 표시 중이면 선택 팔레트 표시하지 않음 + if (document.querySelector('.hl-palette[data-role="edit"]')) return; + const selection = window.getSelection(); const selectedText = selection.toString().trim(); if (selectedText.length > 0 && selection.rangeCount > 0) { @@ -268,11 +287,77 @@ enum ArticleHighlightJS { text: selectedText, hasSelection: true }); + showSelectionPalette(); + } else { + hideSelectionPalette(); } - }, 150); + }, 300); }, { passive: true }); } + // MARK: - 텍스트 선택 시 커스텀 팔레트 (기획서 D 에디트 메뉴) + + const TRASH_SVG = ''; + + function buildPaletteHTML(currentType, showDelete) { + const colors = ['yellow', 'orange', 'pink', 'green', 'blue']; + let html = ''; + colors.forEach(function(color) { + const sel = currentType === color ? ' selected' : ''; + html += '
'; + }); + html += ''; + const uSel = currentType === 'underline' ? ' selected' : ''; + html += '