From df227349782dd8de3bd545195dc6f483c217ce4e Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 1 Aug 2025 09:24:55 -0400 Subject: [PATCH 1/6] Add native block inserter --- Package.swift | 2 +- .../xcshareddata/xcschemes/Gutenberg.xcscheme | 2 +- ios/Demo-iOS/Sources/ContentView.swift | 11 +- ios/Demo-iOS/Sources/EditorView.swift | 6 +- .../BlockInserter+PreviewData.swift | 324 ++++++++++++++ .../BlockInserter/BlockInserterItemView.swift | 55 +++ .../BlockInserterSectionView.swift | 139 ++++++ .../BlockInserter/BlockInserterView.swift | 402 ++++++++++++++++++ .../BlockInserter/EditorBlockType+Icons.swift | 153 +++++++ .../SearchEngine+EditorBlockType.swift | 64 +++ .../GutenbergKit/Sources/EditorBlock.swift | 11 - .../Sources/EditorBlockPicker.swift | 219 ---------- .../Sources/EditorConfiguration.swift | 4 + .../Sources/EditorJSMessage.swift | 4 + .../GutenbergKit/Sources/EditorService.swift | 62 --- .../Sources/EditorViewController.swift | 79 +++- .../Sources/Helpers/SearchEngine.swift | 190 +++++++++ .../Sources/Modifiers/CardModifier.swift | 19 + src/components/editor-toolbar/index.jsx | 39 +- src/components/editor-toolbar/style.scss | 16 + src/components/editor/use-host-bridge.js | 84 +++- src/utils/bridge.js | 33 +- 22 files changed, 1593 insertions(+), 325 deletions(-) create mode 100644 ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserter+PreviewData.swift create mode 100644 ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterItemView.swift create mode 100644 ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterSectionView.swift create mode 100644 ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterView.swift create mode 100644 ios/Sources/GutenbergKit/Sources/BlockInserter/EditorBlockType+Icons.swift create mode 100644 ios/Sources/GutenbergKit/Sources/BlockInserter/SearchEngine+EditorBlockType.swift delete mode 100644 ios/Sources/GutenbergKit/Sources/EditorBlock.swift delete mode 100644 ios/Sources/GutenbergKit/Sources/EditorBlockPicker.swift delete mode 100644 ios/Sources/GutenbergKit/Sources/EditorService.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Helpers/SearchEngine.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Modifiers/CardModifier.swift diff --git a/Package.swift b/Package.swift index 841672f6..61e72de3 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "GutenbergKit", - platforms: [.iOS(.v15), .macOS(.v14)], + platforms: [.iOS(.v16), .macOS(.v14)], products: [ .library(name: "GutenbergKit", targets: ["GutenbergKit"]) ], diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme b/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme index 1a352dfa..6ae6d110 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme @@ -54,7 +54,7 @@ + isEnabled = "YES"> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 8) { + iconView + titleView + .padding(.horizontal, 4) + } + .foregroundStyle(Color.primary) + } + .buttonStyle(.plain) + } + + private var iconView: some View { + BlockIconView(blockType: blockType, size: 44) + } + + private var titleView: some View { + Text(blockTitle) + .font(.caption) + .lineLimit(2, reservesSpace: true) + .multilineTextAlignment(.center) + } + + private var blockTitle: String { + blockType.title ?? blockType.name + .split(separator: "/") + .last + .map(String.init) ?? "Block" + } +} + +struct BlockIconView: View { + let blockType: EditorBlockType + let size: CGFloat + + var body: some View { + ZStack { + // Background + RoundedRectangle(cornerRadius: 12) + .fill(Color(uiColor: .secondarySystemFill)) + .frame(width: size, height: size) + + // Icon + Image(systemName: blockType.iconName) + .font(.system(size: size * 0.5)) + .foregroundColor(.primary) + } + } +} diff --git a/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterSectionView.swift b/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterSectionView.swift new file mode 100644 index 00000000..d6abe3c9 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterSectionView.swift @@ -0,0 +1,139 @@ +import SwiftUI +import PhotosUI + +struct BlockInserterSectionView: View { + let section: BlockInserterSection + let isSearching: Bool + let onBlockSelected: (EditorBlockType) -> Void + let onMediaSelected: ([PhotosPickerItem]) -> Void + + @State private var selectedPhotoItems: [PhotosPickerItem] = [] + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + init( + section: BlockInserterSection, + isSearching: Bool, + onBlockSelected: @escaping (EditorBlockType) -> Void, + onMediaSelected: @escaping ([PhotosPickerItem]) -> Void + ) { + self.section = section + self.isSearching = isSearching + self.onBlockSelected = onBlockSelected + self.onMediaSelected = onMediaSelected + } + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + sectionHeader + .frame(maxWidth: .infinity, alignment: .leading) + .overlay { + if !selectedPhotoItems.isEmpty { + // Shown as an overlay as we don't want to affect the rest of the lasyout + insertButton + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.horizontal) + } + } + if #available(iOS 17.0, *) { + if section.name == "Media" && !isSearching { + VStack(spacing: 12) { + mediaPickerStrip + } + .padding(.bottom, 8) + } + } + + blockGrid + } + .padding(.top, 20) + .padding(.bottom, 12) + .cardStyle() + .padding(.horizontal) + } + + private var sectionHeader: some View { + Text(section.name) + .font(.headline) + .foregroundStyle(Color.secondary) + .padding(.leading, 20) + } + + @available(iOS 17, *) + @ViewBuilder + private var mediaPickerStrip: some View { + PhotosPicker( + "Photos", + selection: $selectedPhotoItems, + maxSelectionCount: 10, + selectionBehavior: .continuousAndOrdered + ) + .labelsHidden() + .photosPickerStyle(.compact) + .photosPickerDisabledCapabilities([.collectionNavigation, .search, .sensitivityAnalysisIntervention, .stagingArea]) + .photosPickerAccessoryVisibility(.hidden) + .frame(height: 110) + .padding(.top, pickerInsetTop) + } + + private var pickerInsetTop: CGFloat { + if #available(iOS 26, *) { + // TODO: remove this workaround when iOS 26 fixes this inset + return -18 + } else { + return 0 + } + } + + @ViewBuilder + private var insertButton: some View { + Button(action: { + onMediaSelected(selectedPhotoItems) + selectedPhotoItems = [] + }) { + // TODO: (Inserter) Needs localization + Text("Insert \(selectedPhotoItems.count) \(selectedPhotoItems.count == 1 ? "Item" : "Items")") + .clipShape(Capsule()) + } + .buttonStyle(.borderedProminent) + + .controlSize(.small) + .tint(Color.primary) + } + + private var blockGrid: some View { + LazyVGrid(columns: gridColumns) { + ForEach(section.blockTypes) { blockType in + BlockInserterItemView(blockType: blockType) { + onBlockSelected(blockType) + } + } + } + .padding(.horizontal, 14) + } + + private var gridColumns: [GridItem] { + return [GridItem(.adaptive(minimum: 80, maximum: 120), spacing: 0)] + } +} + +// MARK: - Media Picker Button + +private struct MediaPickerButton: View { + var body: some View { + VStack(spacing: 8) { + RoundedRectangle(cornerRadius: 12) + .fill(Color(uiColor: .secondarySystemFill)) + .frame(height: 120) + .overlay( + VStack(spacing: 8) { + Image(systemName: "photo.on.rectangle.angled") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("Choose from Photos") + .font(.caption) + .foregroundStyle(.secondary) + } + ) + } + } +} diff --git a/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterView.swift b/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterView.swift new file mode 100644 index 00000000..01a869bb --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterView.swift @@ -0,0 +1,402 @@ +import SwiftUI +import PhotosUI +import Combine +import UniformTypeIdentifiers + +struct BlockInserterView: View { + let blockTypes: [EditorBlockType] + let onBlockSelected: (EditorBlockType) -> Void + + @StateObject private var viewModel: BlockInserterViewModel + + @State private var isShowingFilesPicker = false + @State private var selectedMediaItems: [PhotosPickerItem] = [] + + @Environment(\.dismiss) private var dismiss + + init(blockTypes: [EditorBlockType], + onBlockSelected: @escaping (EditorBlockType) -> Void) { + let blockTypes = blockTypes.filter { $0.title != "Unsupported" } + self.blockTypes = blockTypes.filter { $0.title != "Unsupported" } + self.onBlockSelected = onBlockSelected + self._viewModel = StateObject(wrappedValue: BlockInserterViewModel(blockTypes: blockTypes)) + } + + var body: some View { + NavigationView { + mainContent + .background(Material.ultraThin) + .scrollContentBackground(.hidden) + .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbarContent } + .searchable(text: $viewModel.searchText) + .fileImporter( + isPresented: $isShowingFilesPicker, + allowedContentTypes: [.text, .plainText, .pdf, .image], + allowsMultipleSelection: false, + onCompletion: handleFileImportResult + ) + } + } + + // MARK: - View Components + + private var mainContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + ForEach(viewModel.filteredSections) { section in + BlockInserterSectionView( + section: section, + isSearching: !viewModel.searchText.isEmpty, + onBlockSelected: insertBlock, + onMediaSelected: insertMedia + ) + } + } + .padding(.vertical) + } + } + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + .tint(Color.primary) + } + ToolbarItemGroup(placement: .automatic) { + mediaToolbarButtons + .tint(Color.primary) + } + } + + @ViewBuilder + private var mediaToolbarButtons: some View { + PhotosPicker(selection: $selectedMediaItems) { + Image(systemName: "photo.on.rectangle.angled") + } + .onChange(of: selectedMediaItems) { + insertMedia($0) + selectedMediaItems = [] + } + + Button(action: { + // TODO: Implement camera + print("Camera tapped") + }) { + Image(systemName: "camera") + } + + Menu { + Section { + Button(action: { + // TODO: Implement Image Playground integration + print("Image Playground tapped") + }) { + Label("Image Playground", systemImage: "apple.image.playground") + } + Button(action: { isShowingFilesPicker = true }) { + Label("Files", systemImage: "folder") + } + } + + Section { + Button(action: { + // TODO: Implement Free Photos Library + print("Free Photos Library tapped") + }) { + Label("Free Photos Library", systemImage: "photo.on.rectangle") + } + + Button(action: { + // TODO: Implement Free GIF Library + print("Free GIF Library tapped") + }) { + Label("Free GIF Library", systemImage: "photo.stack") + } + } + + + Section { + // Non-tappable footer showing library size + Button(action: {}) { + HStack { + // TODO: pass this information to the editor + Text("10% of 2 TB used on your site") + .font(.footnote) + .foregroundColor(.secondary) + } + } + .disabled(true) + } + + } label: { + Image(systemName: "ellipsis") + } + } + + // MARK: - File Import Handler + + private func handleFileImportResult(_ result: Result<[URL], Error>) { + switch result { + case .success(let urls): + if let url = urls.first { + handleFileSelection(url) + } + case .failure(let error): + print("File selection error: \(error)") + } + } + + private func handleFileSelection(_ url: URL) { + // TODO: Handle file selection + print("Selected file: \(url.lastPathComponent)") + dismiss() + } + + private func insertBlock(_ blockType: EditorBlockType) { + onBlockSelected(blockType) + dismiss() + } + + private func insertMedia(_ items: [PhotosPickerItem]) { + // TODO: figure out how to allow the editor to access the files (WKWebView needs explicit access to the file system) + } + + private func createImageBlock() -> EditorBlockType { + EditorBlockType( + name: "core/image", + title: "Image", + description: nil, + category: "media", + keywords: nil + ) + } + + private func createVideoBlock() -> EditorBlockType { + EditorBlockType( + name: "core/video", + title: "Video", + description: nil, + category: "media", + keywords: nil + ) + } +} + +// MARK: - View Model + +@MainActor +class BlockInserterViewModel: ObservableObject { + @Published var searchText = "" + @Published private(set) var filteredSections: [BlockInserterSection] = [] + + private let blockTypes: [EditorBlockType] + private let allSections: [BlockInserterSection] + + init(blockTypes: [EditorBlockType]) { + let filteredBlockTypes = blockTypes.filter { $0.title != "Unsupported" } + self.blockTypes = filteredBlockTypes + + self.allSections = BlockInserterViewModel.createSections(from: filteredBlockTypes) + self.filteredSections = allSections + + setupSearchObserver() + } + + private func setupSearchObserver() { + $searchText + .debounce(for: .milliseconds(300), scheduler: RunLoop.main) + .sink { [weak self] searchText in + self?.updateFilteredSections(searchText: searchText) + } + .store(in: &cancellables) + } + + private var cancellables = Set() + + private func updateFilteredSections(searchText: String) { + if searchText.isEmpty { + filteredSections = allSections + } else { + filteredSections = allSections.compactMap { section in + let filtered = filterBlocks(in: section, searchText: searchText) + return filtered.isEmpty ? nil : BlockInserterSection(name: section.name, blockTypes: filtered) + } + } + } + + private func filterBlocks(in section: BlockInserterSection, searchText: String) -> [EditorBlockType] { + let searchEngine = SearchEngine() + let filtered = searchEngine.search(query: searchText, in: section.blockTypes) + + // Maintain paragraph first in text category when showing all blocks + if searchText.isEmpty && section.name == "Text" && filtered.count > 1 { + return sortTextBlocks(filtered) + } + + return filtered + } + + private func sortTextBlocks(_ blocks: [EditorBlockType]) -> [EditorBlockType] { + let paragraphBlocks = blocks.filter { $0.name == "core/paragraph" } + let otherBlocks = blocks.filter { $0.name != "core/paragraph" } + return paragraphBlocks + otherBlocks + } + + private static func createSections(from blockTypes: [EditorBlockType]) -> [BlockInserterSection] { + let categoryOrder = BlockInserterConstants.categoryOrder + var grouped = Dictionary(grouping: blockTypes) { $0.category?.lowercased() ?? "common" } + + // Move core/embed from embed category to media category + if let embedBlocks = grouped["embed"], + let embedBlock = embedBlocks.first(where: { $0.name == "core/embed" }) { + // Add to media category + if var mediaBlocks = grouped["media"] { + mediaBlocks.append(embedBlock) + grouped["media"] = mediaBlocks + } else { + grouped["media"] = [embedBlock] + } + + // Remove from embed category + grouped["embed"] = embedBlocks.filter { $0.name != "core/embed" } + if grouped["embed"]?.isEmpty == true { + grouped.removeValue(forKey: "embed") + } + } + + var sections: [BlockInserterSection] = [] + + // Add sections in WordPress standard order + for (categoryKey, displayName) in categoryOrder { + if let blocks = grouped[categoryKey] { + let sortedBlocks = sortBlocks(blocks, category: categoryKey) + sections.append(BlockInserterSection(name: displayName, blockTypes: sortedBlocks)) + } + } + + // Add any remaining categories + for (category, blocks) in grouped { + let isStandardCategory = categoryOrder.contains { $0.key == category } + if !isStandardCategory { + sections.append(BlockInserterSection(name: category.capitalized, blockTypes: blocks)) + } + } + + return sections + } + + private static func sortBlocks(_ blocks: [EditorBlockType], category: String) -> [EditorBlockType] { + switch category { + case "text": + return sortWithOrder(blocks, order: BlockInserterConstants.textBlockOrder) + case "media": + return sortWithOrder(blocks, order: BlockInserterConstants.mediaBlockOrder) + case "design": + return sortWithOrder(blocks, order: BlockInserterConstants.designBlockOrder) + default: + return blocks + } + } + + private static func sortWithOrder(_ blocks: [EditorBlockType], order: [String]) -> [EditorBlockType] { + var orderedBlocks: [EditorBlockType] = [] + + // Add blocks in defined order + for blockName in order { + if let block = blocks.first(where: { $0.name == blockName }) { + orderedBlocks.append(block) + } + } + + // Add remaining blocks in their original order + let remainingBlocks = blocks.filter { block in + !order.contains(block.name) + } + + return orderedBlocks + remainingBlocks + } +} + +// MARK: - Constants + +enum BlockInserterConstants { + static let categoryOrder: [(key: String, displayName: String)] = [ + ("text", "Text"), + ("media", "Media"), + ("design", "Design"), + ("widgets", "Widgets"), + ("theme", "Theme"), + ("embed", "Embeds") + ] + + static let textBlockOrder = [ + "core/paragraph", + "core/heading", + "core/list", + "core/list-item", + "core/quote", + "core/code", + "core/preformatted", + "core/verse", + "core/table" + ] + + static let mediaBlockOrder = [ + "core/image", + "core/video", + "core/gallery", + "core/embed", + "core/audio", + "core/file" + ] + + static let designBlockOrder = [ + "core/separator", + "core/spacer", + "core/columns", + "core/column" + ] +} + +// MARK: - Extensions + +// MARK: - Supporting Types + +struct BlockInserterSection: Identifiable { + var id: String { name } + let name: String + let blockTypes: [EditorBlockType] +} + +// MARK: - Preview + +import Combine + +struct BlockInserterView_Previews: PreviewProvider { + static var previews: some View { + SheetPreviewContainer() + } +} + +struct SheetPreviewContainer: View { + @State private var isShowingSheet = true + + var body: some View { + Button("Show Block Inserter") { + isShowingSheet = true + } + .popover(isPresented: $isShowingSheet) { + BlockInserterView( + blockTypes: PreviewData.sampleBlockTypes, + onBlockSelected: { blockType in + print("Selected block: \(blockType.name)") + } + ) + } + } +} diff --git a/ios/Sources/GutenbergKit/Sources/BlockInserter/EditorBlockType+Icons.swift b/ios/Sources/GutenbergKit/Sources/BlockInserter/EditorBlockType+Icons.swift new file mode 100644 index 00000000..c7d6bd3c --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/BlockInserter/EditorBlockType+Icons.swift @@ -0,0 +1,153 @@ +import Foundation + +extension EditorBlockType { + /// Returns the SF Symbol icon name for the block type + var iconName: String { + switch name { + // MARK: - Core Text Blocks + case "core/paragraph": "paragraphsign" + case "core/heading": "bookmark.fill" + case "core/list": "list.bullet" + case "core/quote": "quote.opening" + case "core/code": "curlybraces" + case "core/preformatted": "text.alignleft" + case "core/pullquote": "quote.bubble" + case "core/verse": "text.quote" + case "core/table": "tablecells" + + // MARK: - Core Media Blocks + case "core/image": "photo" + case "core/gallery": "photo.stack" + case "core/audio": "speaker.wave.2" + case "core/cover": "photo.tv" + case "core/file": "doc.text" + case "core/media-text": "rectangle.split.2x1" + case "core/video": "video" + + // MARK: - Core Design Blocks + case "core/buttons": "rectangle.3.group" + case "core/columns": "rectangle.split.3x1" + case "core/group": "square.on.square" + case "core/more": "ellipsis" + case "core/nextpage": "arrow.right.doc.on.clipboard" + case "core/separator": "minus" + case "core/spacer": "arrow.up.and.down" + + // MARK: - Core Widget Blocks + case "core/archives": "archivebox" + case "core/calendar": "calendar" + case "core/categories": "folder" + case "core/html": "chevron.left.forwardslash.chevron.right" + case "core/latest-comments": "bubble.left.and.bubble.right" + case "core/latest-posts": "doc.plaintext" + case "core/page-list": "list.bullet.rectangle" + case "core/rss": "dot.radiowaves.left.and.right" + case "core/search": "magnifyingglass" + case "core/shortcode": "curlybraces.square" + case "core/social-links": "person.2.circle" + case "core/tag-cloud": "tag.circle" + + // MARK: - Core Theme Blocks + case "core/site-logo": "seal" + case "core/site-title": "textformat.size" + case "core/site-tagline": "text.bubble" + case "core/query": "square.grid.2x2" + case "core/post-title": "doc.text" + case "core/post-content": "doc.richtext" + case "core/post-excerpt": "doc.append" + case "core/post-featured-image": "photo" + case "core/post-date": "calendar" + case "core/post-author": "person.circle" + case "core/post-comments": "bubble.left" + case "core/post-navigation-link": "arrow.left.arrow.right" + + // MARK: - Core Embed Blocks + case let name where name.hasPrefix("core-embed/"): "link.circle" + + // MARK: - Jetpack AI & Content + case "jetpack/ai-assistant": "sparkles" + case "jetpack/ai-search": "magnifyingglass.circle" + case "jetpack/markdown": "m.square" + case "jetpack/writing-prompt": "pencil.and.outline" + + // MARK: - Jetpack Contact & Forms + case "jetpack/contact-form": "envelope" + case "jetpack/field-text": "textformat" + case "jetpack/field-textarea": "text.alignleft" + case "jetpack/field-email": "envelope" + case "jetpack/field-name": "person" + case "jetpack/field-url": "link" + case "jetpack/field-date": "calendar" + case "jetpack/field-telephone": "phone" + case "jetpack/field-checkbox": "checkmark.square" + case "jetpack/field-checkbox-multiple": "checklist" + case "jetpack/field-radio": "circle.circle" + case "jetpack/field-select": "list.bullet.rectangle" + + // MARK: - Jetpack Media & Galleries + case "jetpack/image-compare": "arrow.left.and.right" + case "jetpack/tiled-gallery": "square.grid.3x3" + case "jetpack/slideshow": "play.rectangle" + case "jetpack/story": "book.pages" + case "jetpack/gif": "sparkles.rectangle.stack" + + // MARK: - Jetpack Social & Embeds + case "jetpack/instagram-gallery": "camera.fill" + case "jetpack/pinterest": "pin.circle" + case "jetpack/eventbrite": "ticket" + case "jetpack/google-calendar": "calendar.badge.clock" + case "jetpack/podcast-player": "mic.circle" + case "jetpack/map": "map" + + // MARK: - Jetpack Business & Contact + case "jetpack/business-hours": "clock" + case "jetpack/contact-info": "info.circle" + case "jetpack/address": "location" + case "jetpack/email": "envelope" + case "jetpack/phone": "phone" + + // MARK: - Jetpack Payments & E-commerce + case "jetpack/recurring-payments": "arrow.clockwise.circle" + case "jetpack/payment-buttons": "creditcard.circle" + case "jetpack/donations": "heart.circle" + case "jetpack/paywall": "lock.rectangle" + case "jetpack/paid-content": "dollarsign.circle" + + // MARK: - Jetpack Marketing & Growth + case "jetpack/subscriptions": "envelope.badge" + case "jetpack/subscriber-login": "person.badge.key" + case "jetpack/mailchimp": "envelope.open" + case "jetpack/sharing-buttons": "square.and.arrow.up" + case "jetpack/whatsapp-button": "message.circle.fill" + case "jetpack/related-posts": "doc.on.doc" + + // MARK: - Jetpack Widgets & Tools + case "jetpack/rating-star": "star.fill" + case "jetpack/repeat-visitor": "person.2" + case "jetpack/cookie-consent": "shield.checkered" + case "jetpack/top-posts": "chart.bar.fill" + case "jetpack/blog-stats": "chart.line.uptrend.xyaxis" + case "jetpack/like": "heart" + case "jetpack/blogroll": "list.bullet.rectangle" + + // MARK: - Jetpack Booking & Reservations + case "jetpack/calendly": "calendar.badge.plus" + case "jetpack/opentable": "fork.knife" + case "jetpack/tock": "clock.badge.checkmark" + + // MARK: - Jetpack Advertising + case "jetpack/ad": "rectangle.badge.plus" + case "jetpack/ads": "rectangle.stack.badge.plus" + + // MARK: - Jetpack External Services + case "jetpack/revue": "newspaper" + case "jetpack/goodreads": "books.vertical" + case "jetpack/loom": "video.bubble" + case "jetpack/descript": "waveform.circle" + case "jetpack/nextdoor": "house.lodge" + + // MARK: - Default + default: "square" + } + } +} diff --git a/ios/Sources/GutenbergKit/Sources/BlockInserter/SearchEngine+EditorBlockType.swift b/ios/Sources/GutenbergKit/Sources/BlockInserter/SearchEngine+EditorBlockType.swift new file mode 100644 index 00000000..9e2537df --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/BlockInserter/SearchEngine+EditorBlockType.swift @@ -0,0 +1,64 @@ +import Foundation + +// MARK: - EditorBlockType Searchable Conformance + +extension EditorBlockType: Searchable { + func searchableFields() -> [SearchableField] { + var fields: [SearchableField] = [] + + // Title - highest weight + if let title = title { + fields.append(SearchableField( + content: title, + weight: 10.0, + allowFuzzyMatch: true + )) + } + + // Name - high weight, strip namespace for better matching + let simplifiedName = name.components(separatedBy: "/").last ?? name + fields.append(SearchableField( + content: simplifiedName, + weight: 8.0, + allowFuzzyMatch: true + )) + + // Keywords - medium weight + if let keywords = keywords { + keywords.forEach { keyword in + fields.append(SearchableField( + content: keyword, + weight: 5.0, + allowFuzzyMatch: true + )) + } + } + + // Description - lower weight, no fuzzy matching + if let description = description { + fields.append(SearchableField( + content: description, + weight: 3.0, + allowFuzzyMatch: false + )) + } + + // Category - lowest weight + if let category = category { + fields.append(SearchableField( + content: category, + weight: 2.0, + allowFuzzyMatch: true + )) + } + + return fields + } +} + +// MARK: - Convenience + +extension SearchEngine where Item == EditorBlockType { + /// Default search engine for editor blocks + static let blocks = SearchEngine() +} \ No newline at end of file diff --git a/ios/Sources/GutenbergKit/Sources/EditorBlock.swift b/ios/Sources/GutenbergKit/Sources/EditorBlock.swift deleted file mode 100644 index 1ad139c4..00000000 --- a/ios/Sources/GutenbergKit/Sources/EditorBlock.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation - -// TODO: hide AnyDecodable -final class EditorBlock: Decodable { - /// The name of the block, e.g. `core/paragraph`. - var name: String - /// The attributes of the block. - var attributes: [String: AnyDecodable] - /// The nested blocks. - var innerBlocks: [EditorBlock] -} diff --git a/ios/Sources/GutenbergKit/Sources/EditorBlockPicker.swift b/ios/Sources/GutenbergKit/Sources/EditorBlockPicker.swift deleted file mode 100644 index 9eb38249..00000000 --- a/ios/Sources/GutenbergKit/Sources/EditorBlockPicker.swift +++ /dev/null @@ -1,219 +0,0 @@ -import SwiftUI - -// TODO: Add search -// TODO: Group these properly -struct EditorBlockPicker: View { - @StateObject var viewModel: EditorBlockPickerViewModel - - @State private var searchText = "" - @State private var group = "Blocks" - - @Environment(\.dismiss) private var dismiss - - - var body: some View { - List { - ForEach(viewModel.displayedSections) { section in - Section(section.name) { - ForEach(section.blockTypes) { blockType in - _Label(blockType.name, systemImage: "paragraphsign") - } - } - } - } - .toolbar(content: { - ToolbarItemGroup(placement: .topBarLeading) { - Button("Close", action: { dismiss() }) - } - ToolbarItemGroup(placement: .topBarTrailing) { - Menu(content: { - Button("Blocks", action: {}) - Button("Patterns", action: {}) - }, label: { - HStack(alignment: .firstTextBaseline) { - Text("Blocks") - .font(.body.weight(.medium)) - Image(systemName: "chevron.down.circle.fill") - .font(.footnote.weight(.bold)) - .foregroundStyle(.secondary, .secondary.opacity(0.3)) - } - }) - } - }) - .navigationBarTitleDisplayMode(.inline) - -// TabView(selection: $group, -// content: { -// _bodyBlocks -// .tabItem { -// Label("Blocks", systemImage: "batteryblock") -// } -// .tag("Blocks") -// Text("Tab Content 2") -// .tabItem { -// Label("Patterns", systemImage: "rectangle.3.group") -// } -// .tag("Patterns") -//// PhotosPicker("Media", selection: $selectedItems) -//// .photosPickerStyle(.inline) -//// .tabItem { -//// Label("Media", systemImage: "photo.on.rectangle") -//// } -//// .tag("Media") -// }) -// .tint(Color.primary) - } - - @ViewBuilder - var _bodyBlocks: some View { - VStack(spacing: 0) { - VStack(spacing: 0) { -// SearchView(text: $searchText) -// .padding(.horizontal, 12) -// .padding(.bottom, 8) -// filters -// .padding(.horizontal) -// .padding(.bottom, 8) - - } - .background(Color(uiColor: .secondarySystemBackground)) - - - List { - Section("Text") { - _Label("Paragraph", systemImage: "paragraphsign") - _Label("Heading", systemImage: "bookmark") - _Label("List", systemImage: "list.triangle") - _Label("Quote", systemImage: "text.quote") - _Label("Table", systemImage: "tablecells") - } - Section("Media") { - _Label("Image", systemImage: "photo") - _Label("Gallery", systemImage: "rectangle.3.group") - _Label("Video", systemImage: "video") - _Label("Audio", systemImage: "waveform") - } - } - .listStyle(.insetGrouped) - } -// .safeAreaInset(edge: .bottom) { -// HStack(spacing: 30) { -// Image(systemName: "paragraphsign") -// Image(systemName: "bookmark") -// Image(systemName: "photo") -// Image(systemName: "rectangle.3.group") -// Image(systemName: "video") -// Image(systemName: "link") -// } -// .padding() -// .background(Color.black.opacity(0.5)) -// .background(Material.thick) -// .foregroundStyle(.white) -// .cornerRadius(16) -// } -// } -// .searchable(text: $searchText)//, placement: .navigationBarDrawer(displayMode: .always)) - .toolbar(content: { - ToolbarItemGroup(placement: .topBarLeading) { - Button("Close", action: { dismiss() }) - } - ToolbarItemGroup(placement: .topBarTrailing) { - Menu(content: { - Button("Blocks", action: {}) - Button("Patterns", action: {}) - }, label: { - HStack(alignment: .firstTextBaseline) { - Text("Blocks") - .font(.body.weight(.medium)) - Image(systemName: "chevron.down.circle.fill") - .font(.footnote.weight(.bold)) - .foregroundStyle(.secondary, .secondary.opacity(0.3)) - } - }) - } - }) - .navigationBarTitleDisplayMode(.inline) -// .toolbar(.hidden, for: .navigationBar) - - .tint(Color.primary) - } - - private var filters: some View { - VStack(spacing: 0) { - HStack(spacing: 0) { - MenuItem("Blocks", isSelected: true) - MenuItem("Patterns", isSelected: false) -// MenuItem("Media") - Spacer() - } - .font(.subheadline) - Divider() - } - } -} - -private struct _Label: View { - let title: String - let systemImage: String - - init(_ title: String, systemImage: String) { - self.title = title - self.systemImage = systemImage - } - - var body: some View { - HStack(spacing: 16) { - Image(systemName: systemImage) - .frame(width: 26) -// .foregroundStyle(.secondary) - Text(title) - Spacer() -// Image(systemName: "info.circle") -// .foregroundColor(.secondary) - } - } -} - -private struct MenuItem: View { - let title: String - let isSelected: Bool - - init(_ title: String, isSelected: Bool = false) { - self.title = title - self.isSelected = isSelected - } - - var body: some View { - VStack { - Text(title) - .fontWeight(isSelected ? .bold : .regular) - .foregroundStyle(isSelected ? Color.primary : Color.secondary) - Rectangle() - .frame(height: 2) - .foregroundStyle(isSelected ? Color.black : Color(uiColor: .separator)) - .opacity(isSelected ? 1 : 0) - } - } -} - -final class EditorBlockPickerViewModel: ObservableObject { - private let sections: [EditorBlockPickerSection] - @Published private(set) var displayedSections: [EditorBlockPickerSection] = [] - - private let blockTypes: [EditorBlockType] = [] - - init(blockTypes: [EditorBlockType]) { - self.sections = Dictionary(grouping: blockTypes, by: \.category) - .map { category, blockTypes in - EditorBlockPickerSection(name: category ?? "–", blockTypes: blockTypes) - } - self.displayedSections = sections - } -} - -struct EditorBlockPickerSection: Identifiable { - var id: String { name } // Guranteed to be unique - - let name: String - let blockTypes: [EditorBlockType] -} diff --git a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift index 87c3446e..6537a088 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift @@ -22,6 +22,10 @@ public struct EditorConfiguration { /// The locale to use for translations public var locale = "en" public var editorAssetsEndpoint: URL? + /// Enable native block inserter UI. + public var enableNativeBlockInserter = true + /// Auto-focus the editor when it loads. + public var autoFocusOnLoad = true public init(title: String = "", content: String = "") { self.title = title diff --git a/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift b/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift index 9f111647..60568474 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift @@ -51,4 +51,8 @@ struct EditorJSMessage { struct DidUpdateFeaturedImageBody: Decodable { let mediaID: Int } + + struct ShowBlockPickerBody: Decodable { + let blockTypes: [EditorBlockType] + } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorService.swift b/ios/Sources/GutenbergKit/Sources/EditorService.swift deleted file mode 100644 index a340bc2c..00000000 --- a/ios/Sources/GutenbergKit/Sources/EditorService.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation - -/// A service that manages the editor backend. -/// -/// - note: The service can be instantiated and warmed-up before the editor -/// is presented. -@MainActor -public final class EditorService { - private let client: EditorNetworkingClient - - @Published private(set) var blockTypes: [EditorBlockType] = [] - @Published private(set) var rawBlockTypesResponseData: Data? - - private var refreshBlockTypesTask: Task<[EditorBlockType], Error>? - - public init(client: EditorNetworkingClient) { - self.client = client - } - - /// Prefetches the settings used by the editor. - public func warmup() async { - _ = try? await refreshBlockTypes() - } - - func refreshBlockTypes() async throws -> [EditorBlockType] { - if let task = refreshBlockTypesTask { - return try await task.value - } - let task = Task { - let request = EditorNetworkRequest(method: "GET", url: URL(string: "./wp-json/wp/v2/block-types")!) - let response = try await self.client.send(request) - try validate(response.urlResponse) - self.blockTypes = try await decode(response.data ?? Data()) - self.rawBlockTypesResponseData = response.data ?? Data() - return blockTypes - } - self.refreshBlockTypesTask = task - return try await task.value - } -} - -// MARK: - Helpers - -private func decode(_ data: Data, using decoder: JSONDecoder = JSONDecoder()) async throws -> T { - try await Task.detached { - try decoder.decode(T.self, from: data) - }.value -} - -private func validate(_ response: URLResponse) throws { - guard let response = response as? HTTPURLResponse else { - throw EditorRequestError.invalidResponseType - } - guard (200..<300).contains(response.statusCode) else { - throw EditorRequestError.unacceptableStatusCode(response.statusCode) - } -} - -enum EditorRequestError: Error { - case invalidResponseType - case unacceptableStatusCode(Int) -} diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index dcfea9a8..4edbfd8c 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -177,6 +177,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro hideTitle: \(configuration.hideTitle), editorSettings: \(editorSettingsJS), locale: '\(configuration.locale)', + enableNativeBlockInserter: \(configuration.enableNativeBlockInserter), post: { id: \(configuration.postID ?? -1), title: '\(escapedTitle)', @@ -279,14 +280,28 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // MARK: - Internal (Block Inserter) - // TODO: wire with JS and pass blocks - private func showBlockInserter() { -// let viewModel = EditorBlockPickerViewModel(blockTypes: service.blockTypes) -// let view = NavigationView { -// EditorBlockPicker(viewModel: viewModel) -// } -// let host = UIHostingController(rootView: view) -// present(host, animated: true) + private func showBlockInserter(blockTypes: [EditorBlockType]) { + let view = BlockInserterView(blockTypes: blockTypes) { [weak self] selectedBlockType in + self?.insertBlock(selectedBlockType) + } + let host = UIHostingController(rootView: view) + host.view.backgroundColor = .clear + + // Configure sheet presentation with medium detent + if let sheet = host.sheetPresentationController { + sheet.detents = [.custom(identifier: .medium, resolver: { context in + context.containerTraitCollection.horizontalSizeClass == .compact ? 534 : 900 + }), .large()] + sheet.prefersGrabberVisible = true + sheet.preferredCornerRadius = 20 + sheet.prefersScrollingExpandsWhenScrolledToEdge = true + } + + present(host, animated: true) + } + + private func insertBlock(_ blockType: EditorBlockType) { + evaluate("window.editor.insertBlock('\(blockType.name)');") } private func openMediaLibrary(_ config: OpenMediaLibraryAction) { @@ -300,6 +315,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // MARK: - GutenbergEditorControllerDelegate fileprivate func controller(_ controller: GutenbergEditorController, didReceiveMessage message: EditorJSMessage) { + print("Received message type: \(message.type)") do { switch message.type { case .onEditorLoaded: @@ -322,7 +338,12 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } delegate?.editor(self, didLogException: editorException) case .showBlockPicker: - showBlockInserter() + do { + let body = try message.decode(EditorJSMessage.ShowBlockPickerBody.self) + showBlockInserter(blockTypes: body.blockTypes) + } catch { + showBlockInserter(blockTypes: []) + } case .openMediaLibrary: let config = try message.decode(OpenMediaLibraryAction.self) openMediaLibrary(config) @@ -344,6 +365,46 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro let duration = CFAbsoluteTimeGetCurrent() - timestampInit print("gutenbergkit-measure_editor-first-render:", duration) delegate?.editorDidLoad(self) + + // Auto-focus the editor after it loads if configured + if configuration.autoFocusOnLoad { + autoFocusEditor() + } + } + + private func autoFocusEditor() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.simulateTapOnWebView() + } + } + + private func simulateTapOnWebView() { + // Use a hidden text field to trigger keyboard, then transfer focus + let hiddenTextField = UITextField(frame: CGRect(x: -100, y: -100, width: 1, height: 1)) + hiddenTextField.autocorrectionType = .no + hiddenTextField.autocapitalizationType = .none + view.addSubview(hiddenTextField) + + // Focus the hidden field to bring up keyboard + hiddenTextField.becomeFirstResponder() + + // After a short delay, transfer focus to web view + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + hiddenTextField.removeFromSuperview() + self?.webView.becomeFirstResponder() + + // Try one more JavaScript focus attempt with keyboard already up + let focusScript = """ + (function() { + const editable = document.querySelector('[contenteditable="true"]'); + if (editable) { + editable.focus(); + editable.click(); + } + })(); + """ + self?.evaluate(focusScript) + } } // MARK: - Warmup diff --git a/ios/Sources/GutenbergKit/Sources/Helpers/SearchEngine.swift b/ios/Sources/GutenbergKit/Sources/Helpers/SearchEngine.swift new file mode 100644 index 00000000..a78d489f --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Helpers/SearchEngine.swift @@ -0,0 +1,190 @@ +import Foundation + +/// A protocol for items that can be searched +protocol Searchable { + /// Extract searchable fields with their weights + func searchableFields() -> [SearchableField] +} + +/// A field that can be searched with an associated weight +struct SearchableField { + let content: String + let weight: Double + let allowFuzzyMatch: Bool + + init(content: String, weight: Double, allowFuzzyMatch: Bool = true) { + self.content = content + self.weight = weight + self.allowFuzzyMatch = allowFuzzyMatch + } +} + +/// Configuration for the search engine +struct SearchConfiguration { + /// Maximum allowed edit distance for fuzzy matching + let maxEditDistance: Int + + /// Minimum similarity threshold (0-1) for fuzzy matches + let minSimilarityThreshold: Double + + /// Multiplier for exact matches + let exactMatchMultiplier: Double + + /// Multiplier for prefix matches + let prefixMatchMultiplier: Double + + /// Multiplier for word prefix matches + let wordPrefixMatchMultiplier: Double + + /// Multiplier for fuzzy matches (applied to similarity score) + let fuzzyMatchMultiplier: Double + + static let `default` = SearchConfiguration( + maxEditDistance: 2, + minSimilarityThreshold: 0.7, + exactMatchMultiplier: 2.0, + prefixMatchMultiplier: 1.5, + wordPrefixMatchMultiplier: 0.8, + fuzzyMatchMultiplier: 0.6 + ) +} + +/// A generic search engine that performs weighted fuzzy search +struct SearchEngine { + + /// Search result with relevance score + struct SearchResult { + let item: Item + let score: Double + } + + let configuration: SearchConfiguration + + init(configuration: SearchConfiguration = .default) { + self.configuration = configuration + } + + /// Search items with weighted fuzzy matching + func search(query: String, in items: [Item]) -> [Item] { + let normalizedQuery = query.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + + // Empty query returns all items + guard !normalizedQuery.isEmpty else { + return items + } + + // Calculate scores for all items + let results: [SearchResult] = items.compactMap { item in + let score = calculateScore(for: item, query: normalizedQuery) + return score > 0 ? SearchResult(item: item, score: score) : nil + } + + // Sort by score (highest first) and return items + return results + .sorted { $0.score > $1.score } + .map { $0.item } + } + + /// Calculate weighted score for an item based on query match + private func calculateScore(for item: Item, query: String) -> Double { + let fields = item.searchableFields() + + return fields.reduce(0.0) { totalScore, field in + totalScore + calculateFieldScore( + field: field.content.lowercased(), + query: query, + weight: field.weight, + allowFuzzy: field.allowFuzzyMatch + ) + } + } + + /// Calculate score for a single field + private func calculateFieldScore(field: String, query: String, weight: Double, allowFuzzy: Bool) -> Double { + // Exact match + if field == query { + return weight * configuration.exactMatchMultiplier + } + + // Contains match + if field.contains(query) { + // Higher score if it starts with the query + if field.hasPrefix(query) { + return weight * configuration.prefixMatchMultiplier + } + return weight + } + + // Fuzzy match if allowed + if allowFuzzy { + // Check each word in the field + let fieldWords = field.split(separator: " ").map(String.init) + for word in fieldWords { + // Word starts with query + if word.hasPrefix(query) { + return weight * configuration.wordPrefixMatchMultiplier + } + + // Calculate similarity + let similarity = calculateSimilarity(word, query) + if similarity >= configuration.minSimilarityThreshold { + return weight * similarity * configuration.fuzzyMatchMultiplier + } + } + + // Try full field fuzzy match for short queries + if query.count <= 10 { + let similarity = calculateSimilarity(field, query) + if similarity >= configuration.minSimilarityThreshold { + return weight * similarity * configuration.fuzzyMatchMultiplier * 0.7 + } + } + } + + return 0 + } + + /// Calculate similarity between two strings using normalized edit distance + private func calculateSimilarity(_ str1: String, _ str2: String) -> Double { + let distance = levenshteinDistance(str1, str2) + let maxLength = max(str1.count, str2.count) + + // Don't allow too many edits relative to string length + if distance > min(configuration.maxEditDistance, maxLength / 3) { + return 0 + } + + return 1.0 - (Double(distance) / Double(maxLength)) + } + + /// Calculate Levenshtein edit distance between two strings + private func levenshteinDistance(_ str1: String, _ str2: String) -> Int { + let str1Array = Array(str1) + let str2Array = Array(str2) + + // Create matrix + var matrix = Array(repeating: Array(repeating: 0, count: str2Array.count + 1), count: str1Array.count + 1) + + // Initialize first row and column + for i in 0...str1Array.count { + matrix[i][0] = i + } + for j in 0...str2Array.count { + matrix[0][j] = j + } + + // Fill matrix + for i in 1...str1Array.count { + for j in 1...str2Array.count { + let cost = str1Array[i-1] == str2Array[j-1] ? 0 : 1 + matrix[i][j] = min( + matrix[i-1][j] + 1, // deletion + matrix[i][j-1] + 1, // insertion + matrix[i-1][j-1] + cost // substitution + ) + } + } + + return matrix[str1Array.count][str2Array.count] + } +} \ No newline at end of file diff --git a/ios/Sources/GutenbergKit/Sources/Modifiers/CardModifier.swift b/ios/Sources/GutenbergKit/Sources/Modifiers/CardModifier.swift new file mode 100644 index 00000000..620db565 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Modifiers/CardModifier.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct CardModifier: ViewModifier { + func body(content: Content) -> some View { + content + .background(Color(.systemBackground)) + .overlay( + RoundedRectangle(cornerRadius: 26) + .stroke(Color(.opaqueSeparator), lineWidth: 0.5) + ) + .clipShape(RoundedRectangle(cornerRadius: 26)) + } +} + +extension View { + func cardStyle() -> some View { + modifier(CardModifier()) + } +} diff --git a/src/components/editor-toolbar/index.jsx b/src/components/editor-toolbar/index.jsx index 67bdd49c..880ffe2c 100644 --- a/src/components/editor-toolbar/index.jsx +++ b/src/components/editor-toolbar/index.jsx @@ -17,7 +17,7 @@ import { ToolbarButton, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { close, cog } from '@wordpress/icons'; +import { close, cog, plus } from '@wordpress/icons'; import clsx from 'clsx'; import { store as editorStore } from '@wordpress/editor'; @@ -26,6 +26,7 @@ import { store as editorStore } from '@wordpress/editor'; */ import './style.scss'; import { useModalize } from './use-modalize'; +import { showBlockPicker, getGBKit } from '../../utils/bridge'; /** * Renders the editor toolbar containing block-related actions. @@ -50,6 +51,10 @@ const EditorToolbar = ( { className } ) => { }, [] ); const { setIsInserterOpened } = useDispatch( editorStore ); + // Check if native block inserter is enabled from configuration + const gbKit = getGBKit(); + const enableNativeBlockInserter = gbKit.enableNativeBlockInserter ?? false; + useModalize( isInserterOpened ); useModalize( isBlockInspectorShown ); @@ -81,14 +86,30 @@ const EditorToolbar = ( { className } ) => { variant="unstyled" > - + { enableNativeBlockInserter ? ( + { + // Close any open web inserter + if ( isInserterOpened ) { + setIsInserterOpened( false ); + } + // Show native block picker + showBlockPicker(); + } } + className="gutenberg-kit-add-block-button" + /> + ) : ( + + ) } { isSelected && ( diff --git a/src/components/editor-toolbar/style.scss b/src/components/editor-toolbar/style.scss index c7d49c8e..c5d8c2f6 100644 --- a/src/components/editor-toolbar/style.scss +++ b/src/components/editor-toolbar/style.scss @@ -43,6 +43,22 @@ $min-touch-target-size: 46px; color: wordpress.$white; } +// Style the add block button with rounded black background +.gutenberg-kit-editor-toolbar .gutenberg-kit-add-block-button { + svg { + background: #eae9ec; + border-radius: 18px; + color: wordpress.$black; + padding: 1px; + width: 32px; + height: 32px; + display: block; + } + + // width: 50px; + margin-left: 8px; +} + // Disable scrolling of the block toolbar, rely upon parent container scrolling .gutenberg-kit-editor-toolbar .block-editor-block-contextual-toolbar { flex-shrink: 0; diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index ed292861..afae5d0e 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -2,18 +2,29 @@ * WordPress dependencies */ import { useEffect, useCallback, useRef } from '@wordpress/element'; -import { useDispatch, useSelect } from '@wordpress/data'; +import { useDispatch, useSelect, dispatch, select } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; import { store as editorStore } from '@wordpress/editor'; -import { parse, serialize } from '@wordpress/blocks'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { parse, serialize, createBlock } from '@wordpress/blocks'; window.editor = window.editor || {}; +window.editor._savedInsertionPoint = null; export function useHostBridge( post, editorRef ) { const { editEntityRecord } = useDispatch( coreStore ); const { undo, redo, switchEditorMode } = useDispatch( editorStore ); const { getEditedPostAttribute, getEditedPostContent } = useSelect( editorStore ); + + // Track the current selection and insertion point + const { selectedBlockClientId, blockInsertionPoint } = useSelect( ( select ) => { + const { getSelectedBlockClientId, getBlockInsertionPoint } = select( blockEditorStore ); + return { + selectedBlockClientId: getSelectedBlockClientId(), + blockInsertionPoint: getBlockInsertionPoint(), + }; + }, [] ); const editContent = useCallback( ( edits ) => { @@ -28,6 +39,30 @@ export function useHostBridge( post, editorRef ) { postContentRef.current = serialize( parse( post.content.raw || '' ) ); } + // Continuously update the saved insertion point whenever selection changes + useEffect( () => { + // Only update if we have a selected block OR if we're clearing selection but already have a saved point + if ( selectedBlockClientId !== null ) { + // We have a selected block, save its position + window.editor._savedInsertionPoint = { + rootClientId: blockInsertionPoint?.rootClientId, + index: blockInsertionPoint?.index || 0, + selectedBlockClientId: selectedBlockClientId + }; + } else if ( !window.editor._savedInsertionPoint || !window.editor._savedInsertionPoint.selectedBlockClientId ) { + // Only update to null selection if we don't have a previously selected block + // This prevents overwriting a good insertion point when focus is lost + if ( blockInsertionPoint && blockInsertionPoint.index !== null ) { + window.editor._savedInsertionPoint = { + rootClientId: blockInsertionPoint?.rootClientId, + index: blockInsertionPoint?.index || 0, + selectedBlockClientId: null + }; + } + } + // If selectedBlockClientId is null but we had a previous selection, keep the old insertion point + }, [ selectedBlockClientId, blockInsertionPoint ] ); + useEffect( () => { window.editor.setContent = ( content ) => { editContent( { content: decodeURIComponent( content ) } ); @@ -79,6 +114,49 @@ export function useHostBridge( post, editorRef ) { switchEditorMode( mode ); }; + window.editor.insertBlock = ( blockName ) => { + try { + const block = createBlock( blockName ); + + // Check if we have a saved insertion point + if ( window.editor._savedInsertionPoint ) { + const { selectedBlockClientId, index, rootClientId } = window.editor._savedInsertionPoint; + + // Try to use insertBlocks (plural) which might handle positioning better + const { insertBlocks } = dispatch( blockEditorStore ); + + if ( selectedBlockClientId ) { + // We have a selected block, insert after it + // First, try to get the block directly + const selectedBlock = select( blockEditorStore ).getBlock( selectedBlockClientId ); + if ( selectedBlock ) { + const parentClientId = select( blockEditorStore ).getBlockRootClientId( selectedBlockClientId ); + const blockIndex = select( blockEditorStore ).getBlockIndex( selectedBlockClientId ); + + // Use insertBlocks with explicit position + insertBlocks( [ block ], blockIndex + 1, parentClientId ); + } else { + insertBlocks( [ block ], index, rootClientId ); + } + } else { + // No selected block, use the saved index + insertBlocks( [ block ], index, rootClientId ); + } + } else { + // No saved insertion point + dispatch( blockEditorStore ).insertBlock( block ); + } + + // Select the newly inserted block to help with focusing + setTimeout( () => { + dispatch( blockEditorStore ).selectBlock( block.clientId ); + }, 100 ); + + } catch ( error ) { + console.error( 'Error in insertBlock:', error ); + } + }; + return () => { delete window.editor.setContent; delete window.editor.setTitle; @@ -87,6 +165,8 @@ export function useHostBridge( post, editorRef ) { delete window.editor.undo; delete window.editor.redo; delete window.editor.switchEditorMode; + delete window.editor.insertBlock; + window.editor._savedInsertionPoint = null; }; }, [ editorRef, diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 9bcb5da2..ffac4cbe 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -2,6 +2,7 @@ * WordPress dependencies */ import apiFetch from '@wordpress/api-fetch'; +import { getBlockTypes } from '@wordpress/blocks'; /** * Internal dependencies @@ -110,15 +111,31 @@ export function onBlocksChanged( isEmpty = false ) { * @return {void} */ export function showBlockPicker() { - if ( window.editorDelegate ) { - window.editorDelegate.showBlockPicker(); - } + // Get all registered block types + const allBlockTypes = getBlockTypes(); + const blockTypes = allBlockTypes.map( ( blockType ) => { + return { + name: blockType.name, + title: blockType.title, + description: blockType.description, + category: blockType.category, + keywords: blockType.keywords || [], + }; + } ); - if ( window.webkit ) { - window.webkit.messageHandlers.editorDelegate.postMessage( { - message: 'showBlockPicker', - body: {}, - } ); + try { + if ( window.editorDelegate ) { + window.editorDelegate.showBlockPicker( JSON.stringify( { blockTypes } ) ); + } + + if ( window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.editorDelegate ) { + window.webkit.messageHandlers.editorDelegate.postMessage( { + message: 'showBlockPicker', + body: { blockTypes }, + } ); + } + } catch ( error ) { + console.error( 'Error sending message to native:', error ); } } From 1dac78c7644c7d5067722045880aa5da5366940c Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 1 Aug 2025 13:50:31 -0400 Subject: [PATCH 2/6] Disable by default --- .../xcshareddata/xcschemes/Gutenberg.xcscheme | 2 +- ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme b/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme index 6ae6d110..1a352dfa 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme @@ -54,7 +54,7 @@ + isEnabled = "NO"> Date: Fri, 1 Aug 2025 14:32:11 -0400 Subject: [PATCH 3/6] Show the media strip above the first category --- ios/Demo-iOS/Sources/ContentView.swift | 1 + .../BlockInserterSectionView.swift | 63 ------------------- .../BlockInserter/BlockInserterView.swift | 23 ++++++- .../Sources/EditorViewController.swift | 2 +- 4 files changed, 24 insertions(+), 65 deletions(-) diff --git a/ios/Demo-iOS/Sources/ContentView.swift b/ios/Demo-iOS/Sources/ContentView.swift index a07c7b73..69870e0d 100644 --- a/ios/Demo-iOS/Sources/ContentView.swift +++ b/ios/Demo-iOS/Sources/ContentView.swift @@ -85,6 +85,7 @@ private extension EditorConfiguration { configuration.editorAssetsEndpoint = URL(string: configuration.siteApiRoot)!.appendingPathComponent("wpcom/v2/editor-assets") // The `plugins: true` is necessary for the editor to use 'remote.html' configuration.plugins = true + configuration.enableNativeBlockInserter = true return configuration } diff --git a/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterSectionView.swift b/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterSectionView.swift index d6abe3c9..54a52e48 100644 --- a/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterSectionView.swift +++ b/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterSectionView.swift @@ -3,21 +3,17 @@ import PhotosUI struct BlockInserterSectionView: View { let section: BlockInserterSection - let isSearching: Bool let onBlockSelected: (EditorBlockType) -> Void let onMediaSelected: ([PhotosPickerItem]) -> Void - @State private var selectedPhotoItems: [PhotosPickerItem] = [] @Environment(\.horizontalSizeClass) private var horizontalSizeClass init( section: BlockInserterSection, - isSearching: Bool, onBlockSelected: @escaping (EditorBlockType) -> Void, onMediaSelected: @escaping ([PhotosPickerItem]) -> Void ) { self.section = section - self.isSearching = isSearching self.onBlockSelected = onBlockSelected self.onMediaSelected = onMediaSelected } @@ -26,23 +22,6 @@ struct BlockInserterSectionView: View { VStack(alignment: .leading, spacing: 20) { sectionHeader .frame(maxWidth: .infinity, alignment: .leading) - .overlay { - if !selectedPhotoItems.isEmpty { - // Shown as an overlay as we don't want to affect the rest of the lasyout - insertButton - .frame(maxWidth: .infinity, alignment: .trailing) - .padding(.horizontal) - } - } - if #available(iOS 17.0, *) { - if section.name == "Media" && !isSearching { - VStack(spacing: 12) { - mediaPickerStrip - } - .padding(.bottom, 8) - } - } - blockGrid } .padding(.top, 20) @@ -58,48 +37,6 @@ struct BlockInserterSectionView: View { .padding(.leading, 20) } - @available(iOS 17, *) - @ViewBuilder - private var mediaPickerStrip: some View { - PhotosPicker( - "Photos", - selection: $selectedPhotoItems, - maxSelectionCount: 10, - selectionBehavior: .continuousAndOrdered - ) - .labelsHidden() - .photosPickerStyle(.compact) - .photosPickerDisabledCapabilities([.collectionNavigation, .search, .sensitivityAnalysisIntervention, .stagingArea]) - .photosPickerAccessoryVisibility(.hidden) - .frame(height: 110) - .padding(.top, pickerInsetTop) - } - - private var pickerInsetTop: CGFloat { - if #available(iOS 26, *) { - // TODO: remove this workaround when iOS 26 fixes this inset - return -18 - } else { - return 0 - } - } - - @ViewBuilder - private var insertButton: some View { - Button(action: { - onMediaSelected(selectedPhotoItems) - selectedPhotoItems = [] - }) { - // TODO: (Inserter) Needs localization - Text("Insert \(selectedPhotoItems.count) \(selectedPhotoItems.count == 1 ? "Item" : "Items")") - .clipShape(Capsule()) - } - .buttonStyle(.borderedProminent) - - .controlSize(.small) - .tint(Color.primary) - } - private var blockGrid: some View { LazyVGrid(columns: gridColumns) { ForEach(section.blockTypes) { blockType in diff --git a/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterView.swift b/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterView.swift index 01a869bb..ab865d2d 100644 --- a/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterView.swift +++ b/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterView.swift @@ -11,6 +11,7 @@ struct BlockInserterView: View { @State private var isShowingFilesPicker = false @State private var selectedMediaItems: [PhotosPickerItem] = [] + @State private var inlineSelectedMediaItems: [PhotosPickerItem] = [] @Environment(\.dismiss) private var dismiss @@ -44,10 +45,30 @@ struct BlockInserterView: View { private var mainContent: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { + if viewModel.searchText.isEmpty { + if #available(iOS 17, *) { + PhotosPicker( + "Photos", + selection: $inlineSelectedMediaItems, + maxSelectionCount: 10, + selectionBehavior: .continuousAndOrdered + ) + .labelsHidden() + .photosPickerStyle(.compact) + .photosPickerDisabledCapabilities([.collectionNavigation, .search, .sensitivityAnalysisIntervention, .stagingArea]) + .photosPickerAccessoryVisibility(.hidden, edges: .all) + .frame(height: 128) + .clipShape(UnevenRoundedRectangle( + topLeadingRadius: 20, bottomLeadingRadius: 20, bottomTrailingRadius: 0, topTrailingRadius: 0 + )) + .padding(.leading) + + } + } + ForEach(viewModel.filteredSections) { section in BlockInserterSectionView( section: section, - isSearching: !viewModel.searchText.isEmpty, onBlockSelected: insertBlock, onMediaSelected: insertMedia ) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 4edbfd8c..ea9d3616 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -290,7 +290,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // Configure sheet presentation with medium detent if let sheet = host.sheetPresentationController { sheet.detents = [.custom(identifier: .medium, resolver: { context in - context.containerTraitCollection.horizontalSizeClass == .compact ? 534 : 900 + context.containerTraitCollection.horizontalSizeClass == .compact ? 508 : 900 }), .large()] sheet.prefersGrabberVisible = true sheet.preferredCornerRadius = 20 From 80d00b34e08096934c105fad7bdcf3c6049c6295 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 1 Aug 2025 14:49:14 -0400 Subject: [PATCH 4/6] Dont show Text category section title --- .../BlockInserter/BlockInserterSectionView.swift | 8 +++++--- .../Sources/BlockInserter/BlockInserterView.swift | 15 ++++++++++----- .../Sources/EditorViewController.swift | 9 ++++++++- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterSectionView.swift b/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterSectionView.swift index 54a52e48..0a39bf77 100644 --- a/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterSectionView.swift +++ b/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterSectionView.swift @@ -20,11 +20,13 @@ struct BlockInserterSectionView: View { var body: some View { VStack(alignment: .leading, spacing: 20) { - sectionHeader - .frame(maxWidth: .infinity, alignment: .leading) + if section.category != "text" { + sectionHeader + .frame(maxWidth: .infinity, alignment: .leading) + } blockGrid } - .padding(.top, 20) + .padding(.top, section.category != "text" ? 20 : 28) .padding(.bottom, 12) .cardStyle() .padding(.horizontal) diff --git a/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterView.swift b/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterView.swift index ab865d2d..8e906de2 100644 --- a/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterView.swift +++ b/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterView.swift @@ -57,7 +57,7 @@ struct BlockInserterView: View { .photosPickerStyle(.compact) .photosPickerDisabledCapabilities([.collectionNavigation, .search, .sensitivityAnalysisIntervention, .stagingArea]) .photosPickerAccessoryVisibility(.hidden, edges: .all) - .frame(height: 128) + .frame(height: 110) .clipShape(UnevenRoundedRectangle( topLeadingRadius: 20, bottomLeadingRadius: 20, bottomTrailingRadius: 0, topTrailingRadius: 0 )) @@ -245,7 +245,11 @@ class BlockInserterViewModel: ObservableObject { } else { filteredSections = allSections.compactMap { section in let filtered = filterBlocks(in: section, searchText: searchText) - return filtered.isEmpty ? nil : BlockInserterSection(name: section.name, blockTypes: filtered) + return filtered.isEmpty ? nil : BlockInserterSection( + category: section.category, + name: section.name, + blockTypes: filtered + ) } } } @@ -296,7 +300,7 @@ class BlockInserterViewModel: ObservableObject { for (categoryKey, displayName) in categoryOrder { if let blocks = grouped[categoryKey] { let sortedBlocks = sortBlocks(blocks, category: categoryKey) - sections.append(BlockInserterSection(name: displayName, blockTypes: sortedBlocks)) + sections.append(BlockInserterSection(category: categoryKey, name: displayName, blockTypes: sortedBlocks)) } } @@ -304,7 +308,7 @@ class BlockInserterViewModel: ObservableObject { for (category, blocks) in grouped { let isStandardCategory = categoryOrder.contains { $0.key == category } if !isStandardCategory { - sections.append(BlockInserterSection(name: category.capitalized, blockTypes: blocks)) + sections.append(BlockInserterSection(category: category, name: category.capitalized, blockTypes: blocks)) } } @@ -389,7 +393,8 @@ enum BlockInserterConstants { // MARK: - Supporting Types struct BlockInserterSection: Identifiable { - var id: String { name } + var id: String { category } + let category: String let name: String let blockTypes: [EditorBlockType] } diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index ea9d3616..8a42d98c 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -289,8 +289,15 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // Configure sheet presentation with medium detent if let sheet = host.sheetPresentationController { + let compactHeight: CGFloat + if #available(iOS 26, *) { + compactHeight = 548 + } else { + compactHeight = 566 + } + sheet.detents = [.custom(identifier: .medium, resolver: { context in - context.containerTraitCollection.horizontalSizeClass == .compact ? 508 : 900 + context.containerTraitCollection.horizontalSizeClass == .compact ? compactHeight : 900 }), .large()] sheet.prefersGrabberVisible = true sheet.preferredCornerRadius = 20 From 5572c4a9100989a99d27adccf12f65124170b09b Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 4 Aug 2025 06:34:55 -0400 Subject: [PATCH 5/6] Refactor --- .../xcshareddata/xcschemes/Gutenberg.xcscheme | 2 +- ios/Demo-iOS/Sources/ContentView.swift | 45 +- ios/Demo-iOS/Sources/EditorView.swift | 99 +++- .../BlockInserter/BlockInserterItemView.swift | 55 --- .../BlockInserterSectionView.swift | 78 ---- .../BlockInserter/BlockInserterView.swift | 428 ------------------ .../Sources/EditorJSMessage.swift | 2 +- .../GutenbergKit/Sources/EditorTypes.swift | 2 +- .../Sources/EditorViewController.swift | 141 +++--- .../EditorViewControllerDelegate.swift | 28 +- .../Sources/Helpers/EditorFileManager.swift | 94 ++++ .../Helpers/EditorFileSchemeHandler.swift | 29 ++ .../Sources/Media/MediaInfo.swift | 25 + .../Sources/Media/MediaPickerAction.swift | 37 ++ .../BlockInserterBlockView.swift | 109 +++++ .../BlockInserterSectionView.swift | 49 ++ .../BlockInserter/BlockInserterView.swift | 243 ++++++++++ .../BlockInserterViewModel.swift | 330 ++++++++++++++ .../BlockInserter/EditorBlock+Icons.swift} | 57 ++- .../EditorBlock+PreviewData.swift} | 90 ++-- .../SearchEngine+EditorBlock.swift} | 12 +- .../Sources/Views/CameraView.swift | 51 +++ src/components/editor/use-host-bridge.js | 77 +++- src/utils/bridge.js | 131 +++++- 24 files changed, 1459 insertions(+), 755 deletions(-) delete mode 100644 ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterItemView.swift delete mode 100644 ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterSectionView.swift delete mode 100644 ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterView.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Helpers/EditorFileManager.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Helpers/EditorFileSchemeHandler.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Media/MediaInfo.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Media/MediaPickerAction.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterBlockView.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift rename ios/Sources/GutenbergKit/Sources/{BlockInserter/EditorBlockType+Icons.swift => Views/BlockInserter/EditorBlock+Icons.swift} (68%) rename ios/Sources/GutenbergKit/Sources/{BlockInserter/BlockInserter+PreviewData.swift => Views/BlockInserter/EditorBlock+PreviewData.swift} (90%) rename ios/Sources/GutenbergKit/Sources/{BlockInserter/SearchEngine+EditorBlockType.swift => Views/BlockInserter/SearchEngine+EditorBlock.swift} (87%) create mode 100644 ios/Sources/GutenbergKit/Sources/Views/CameraView.swift diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme b/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme index 1a352dfa..6ae6d110 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme @@ -54,7 +54,7 @@ + isEnabled = "YES"> EditorConfiguration { + var config = configuration + config.enableNativeBlockInserter = enableNativeBlockInserter + config.plugins = enablePlugins + config.hideTitle = hideTitle + config.autoFocusOnLoad = autoFocusOnLoad + config.themeStyles = themeStyles + return config + } } private extension EditorConfiguration { @@ -75,8 +111,6 @@ private extension EditorConfiguration { static var template: Self { var configuration = EditorConfiguration.default - #warning("1. Update the property values below") - #warning("2. Install the Jetpack plugin to the site") configuration.siteURL = "https://modify-me.com" configuration.authHeader = "Insert the Authorization header value here" @@ -85,7 +119,6 @@ private extension EditorConfiguration { configuration.editorAssetsEndpoint = URL(string: configuration.siteApiRoot)!.appendingPathComponent("wpcom/v2/editor-assets") // The `plugins: true` is necessary for the editor to use 'remote.html' configuration.plugins = true - configuration.enableNativeBlockInserter = true return configuration } diff --git a/ios/Demo-iOS/Sources/EditorView.swift b/ios/Demo-iOS/Sources/EditorView.swift index c9afff4c..0fcf3878 100644 --- a/ios/Demo-iOS/Sources/EditorView.swift +++ b/ios/Demo-iOS/Sources/EditorView.swift @@ -83,9 +83,13 @@ private struct _EditorView: UIViewControllerRepresentable { self.configuration = configuration } + func makeCoordinator() -> Coordinator { + Coordinator() + } + func makeUIViewController(context: Context) -> EditorViewController { let viewController = EditorViewController(configuration: configuration) - + viewController.delegate = context.coordinator if #available(iOS 16.4, *) { viewController.webView.isInspectable = true } @@ -96,6 +100,99 @@ private struct _EditorView: UIViewControllerRepresentable { func updateUIViewController(_ uiViewController: EditorViewController, context: Context) { // Do nothing } + + class Coordinator: NSObject, EditorViewControllerDelegate { + func editorDidLoad(_ viewContoller: EditorViewController) { + print("Editor did load") + } + + func editor(_ viewContoller: EditorViewController, didDisplayInitialContent content: String) { + print("Editor displayed initial content") + } + + func editor(_ viewContoller: EditorViewController, didEncounterCriticalError error: Error) { + print("Editor critical error: \(error)") + } + + func editor(_ viewController: EditorViewController, didUpdateContentWithState state: EditorState) { + // Handle content updates + } + + func editor(_ viewController: EditorViewController, didUpdateHistoryState state: EditorState) { + // Handle history updates + } + + func editor(_ viewController: EditorViewController, didUpdateFeaturedImage mediaID: Int) { + print("Featured image updated: \(mediaID)") + } + + func editor(_ viewController: EditorViewController, didLogException error: GutenbergJSException) { + print("JS Exception: \(error.message)") + } + + func editor(_ viewController: EditorViewController, didRequestMediaFromSiteMediaLibrary config: OpenMediaLibraryAction) { + print("Requested site media library") + } + + func getMediaPickerController(for viewController: EditorViewController, parameters: MediaPickerParameters) -> (any MediaPickerController)? { + MockMediaPickerController() + } + } +} + +private struct MockMediaPickerController: MediaPickerController { + var actions: [[MediaPickerAction]] = [[ + MediaPickerAction( + id: "image-playground", + title: "Image Playground", + image: UIImage(systemName: "apple.image.playground")! + ) { presentingViewController, completion in + showPickerAlert(for: "Image Playground", in: presentingViewController, completion: completion) + }, + MediaPickerAction( + id: "files", + title: "Files", + image: UIImage(systemName: "folder")! + ) { presentingViewController, completion in + showPickerAlert(for: "Files", in: presentingViewController, completion: completion) + } + ], [ + MediaPickerAction( + id: "free-photos", + title: "Free Photos Library", + image: UIImage(systemName: "photo.on.rectangle")! + ) { presentingViewController, completion in + showPickerAlert(for: "Free Photos Library", in: presentingViewController, completion: completion) + }, + MediaPickerAction( + id: "free-gifs", + title: "Free GIF Library", + image: UIImage(systemName: "photo.stack")! + ) { presentingViewController, completion in + showPickerAlert(for: "Free GIF Library", in: presentingViewController, completion: completion) + } + ]] +} + +private func showPickerAlert(for pickerName: String, in viewController: UIViewController, completion: @escaping ([MediaInfo]) -> Void) { + let alert = UIAlertController( + title: "Media Picker", + message: "Picker not implemented: \(pickerName)", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in + completion([]) + }) + viewController.present(alert, animated: true) +} + +private extension UIViewController { + var topPresentedViewController: UIViewController { + guard let presentedViewController else { + return self + } + return presentedViewController.topPresentedViewController + } } #Preview { diff --git a/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterItemView.swift b/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterItemView.swift deleted file mode 100644 index dee59402..00000000 --- a/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterItemView.swift +++ /dev/null @@ -1,55 +0,0 @@ -import SwiftUI - -struct BlockInserterItemView: View { - let blockType: EditorBlockType - let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(spacing: 8) { - iconView - titleView - .padding(.horizontal, 4) - } - .foregroundStyle(Color.primary) - } - .buttonStyle(.plain) - } - - private var iconView: some View { - BlockIconView(blockType: blockType, size: 44) - } - - private var titleView: some View { - Text(blockTitle) - .font(.caption) - .lineLimit(2, reservesSpace: true) - .multilineTextAlignment(.center) - } - - private var blockTitle: String { - blockType.title ?? blockType.name - .split(separator: "/") - .last - .map(String.init) ?? "Block" - } -} - -struct BlockIconView: View { - let blockType: EditorBlockType - let size: CGFloat - - var body: some View { - ZStack { - // Background - RoundedRectangle(cornerRadius: 12) - .fill(Color(uiColor: .secondarySystemFill)) - .frame(width: size, height: size) - - // Icon - Image(systemName: blockType.iconName) - .font(.system(size: size * 0.5)) - .foregroundColor(.primary) - } - } -} diff --git a/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterSectionView.swift b/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterSectionView.swift deleted file mode 100644 index 0a39bf77..00000000 --- a/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterSectionView.swift +++ /dev/null @@ -1,78 +0,0 @@ -import SwiftUI -import PhotosUI - -struct BlockInserterSectionView: View { - let section: BlockInserterSection - let onBlockSelected: (EditorBlockType) -> Void - let onMediaSelected: ([PhotosPickerItem]) -> Void - - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - - init( - section: BlockInserterSection, - onBlockSelected: @escaping (EditorBlockType) -> Void, - onMediaSelected: @escaping ([PhotosPickerItem]) -> Void - ) { - self.section = section - self.onBlockSelected = onBlockSelected - self.onMediaSelected = onMediaSelected - } - - var body: some View { - VStack(alignment: .leading, spacing: 20) { - if section.category != "text" { - sectionHeader - .frame(maxWidth: .infinity, alignment: .leading) - } - blockGrid - } - .padding(.top, section.category != "text" ? 20 : 28) - .padding(.bottom, 12) - .cardStyle() - .padding(.horizontal) - } - - private var sectionHeader: some View { - Text(section.name) - .font(.headline) - .foregroundStyle(Color.secondary) - .padding(.leading, 20) - } - - private var blockGrid: some View { - LazyVGrid(columns: gridColumns) { - ForEach(section.blockTypes) { blockType in - BlockInserterItemView(blockType: blockType) { - onBlockSelected(blockType) - } - } - } - .padding(.horizontal, 14) - } - - private var gridColumns: [GridItem] { - return [GridItem(.adaptive(minimum: 80, maximum: 120), spacing: 0)] - } -} - -// MARK: - Media Picker Button - -private struct MediaPickerButton: View { - var body: some View { - VStack(spacing: 8) { - RoundedRectangle(cornerRadius: 12) - .fill(Color(uiColor: .secondarySystemFill)) - .frame(height: 120) - .overlay( - VStack(spacing: 8) { - Image(systemName: "photo.on.rectangle.angled") - .font(.largeTitle) - .foregroundStyle(.secondary) - Text("Choose from Photos") - .font(.caption) - .foregroundStyle(.secondary) - } - ) - } - } -} diff --git a/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterView.swift b/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterView.swift deleted file mode 100644 index 8e906de2..00000000 --- a/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserterView.swift +++ /dev/null @@ -1,428 +0,0 @@ -import SwiftUI -import PhotosUI -import Combine -import UniformTypeIdentifiers - -struct BlockInserterView: View { - let blockTypes: [EditorBlockType] - let onBlockSelected: (EditorBlockType) -> Void - - @StateObject private var viewModel: BlockInserterViewModel - - @State private var isShowingFilesPicker = false - @State private var selectedMediaItems: [PhotosPickerItem] = [] - @State private var inlineSelectedMediaItems: [PhotosPickerItem] = [] - - @Environment(\.dismiss) private var dismiss - - init(blockTypes: [EditorBlockType], - onBlockSelected: @escaping (EditorBlockType) -> Void) { - let blockTypes = blockTypes.filter { $0.title != "Unsupported" } - self.blockTypes = blockTypes.filter { $0.title != "Unsupported" } - self.onBlockSelected = onBlockSelected - self._viewModel = StateObject(wrappedValue: BlockInserterViewModel(blockTypes: blockTypes)) - } - - var body: some View { - NavigationView { - mainContent - .background(Material.ultraThin) - .scrollContentBackground(.hidden) - .navigationBarTitleDisplayMode(.inline) - .toolbar { toolbarContent } - .searchable(text: $viewModel.searchText) - .fileImporter( - isPresented: $isShowingFilesPicker, - allowedContentTypes: [.text, .plainText, .pdf, .image], - allowsMultipleSelection: false, - onCompletion: handleFileImportResult - ) - } - } - - // MARK: - View Components - - private var mainContent: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - if viewModel.searchText.isEmpty { - if #available(iOS 17, *) { - PhotosPicker( - "Photos", - selection: $inlineSelectedMediaItems, - maxSelectionCount: 10, - selectionBehavior: .continuousAndOrdered - ) - .labelsHidden() - .photosPickerStyle(.compact) - .photosPickerDisabledCapabilities([.collectionNavigation, .search, .sensitivityAnalysisIntervention, .stagingArea]) - .photosPickerAccessoryVisibility(.hidden, edges: .all) - .frame(height: 110) - .clipShape(UnevenRoundedRectangle( - topLeadingRadius: 20, bottomLeadingRadius: 20, bottomTrailingRadius: 0, topTrailingRadius: 0 - )) - .padding(.leading) - - } - } - - ForEach(viewModel.filteredSections) { section in - BlockInserterSectionView( - section: section, - onBlockSelected: insertBlock, - onMediaSelected: insertMedia - ) - } - } - .padding(.vertical) - } - } - - @ToolbarContentBuilder - private var toolbarContent: some ToolbarContent { - ToolbarItem(placement: .cancellationAction) { - Button { - dismiss() - } label: { - Image(systemName: "xmark") - } - .tint(Color.primary) - } - ToolbarItemGroup(placement: .automatic) { - mediaToolbarButtons - .tint(Color.primary) - } - } - - @ViewBuilder - private var mediaToolbarButtons: some View { - PhotosPicker(selection: $selectedMediaItems) { - Image(systemName: "photo.on.rectangle.angled") - } - .onChange(of: selectedMediaItems) { - insertMedia($0) - selectedMediaItems = [] - } - - Button(action: { - // TODO: Implement camera - print("Camera tapped") - }) { - Image(systemName: "camera") - } - - Menu { - Section { - Button(action: { - // TODO: Implement Image Playground integration - print("Image Playground tapped") - }) { - Label("Image Playground", systemImage: "apple.image.playground") - } - Button(action: { isShowingFilesPicker = true }) { - Label("Files", systemImage: "folder") - } - } - - Section { - Button(action: { - // TODO: Implement Free Photos Library - print("Free Photos Library tapped") - }) { - Label("Free Photos Library", systemImage: "photo.on.rectangle") - } - - Button(action: { - // TODO: Implement Free GIF Library - print("Free GIF Library tapped") - }) { - Label("Free GIF Library", systemImage: "photo.stack") - } - } - - - Section { - // Non-tappable footer showing library size - Button(action: {}) { - HStack { - // TODO: pass this information to the editor - Text("10% of 2 TB used on your site") - .font(.footnote) - .foregroundColor(.secondary) - } - } - .disabled(true) - } - - } label: { - Image(systemName: "ellipsis") - } - } - - // MARK: - File Import Handler - - private func handleFileImportResult(_ result: Result<[URL], Error>) { - switch result { - case .success(let urls): - if let url = urls.first { - handleFileSelection(url) - } - case .failure(let error): - print("File selection error: \(error)") - } - } - - private func handleFileSelection(_ url: URL) { - // TODO: Handle file selection - print("Selected file: \(url.lastPathComponent)") - dismiss() - } - - private func insertBlock(_ blockType: EditorBlockType) { - onBlockSelected(blockType) - dismiss() - } - - private func insertMedia(_ items: [PhotosPickerItem]) { - // TODO: figure out how to allow the editor to access the files (WKWebView needs explicit access to the file system) - } - - private func createImageBlock() -> EditorBlockType { - EditorBlockType( - name: "core/image", - title: "Image", - description: nil, - category: "media", - keywords: nil - ) - } - - private func createVideoBlock() -> EditorBlockType { - EditorBlockType( - name: "core/video", - title: "Video", - description: nil, - category: "media", - keywords: nil - ) - } -} - -// MARK: - View Model - -@MainActor -class BlockInserterViewModel: ObservableObject { - @Published var searchText = "" - @Published private(set) var filteredSections: [BlockInserterSection] = [] - - private let blockTypes: [EditorBlockType] - private let allSections: [BlockInserterSection] - - init(blockTypes: [EditorBlockType]) { - let filteredBlockTypes = blockTypes.filter { $0.title != "Unsupported" } - self.blockTypes = filteredBlockTypes - - self.allSections = BlockInserterViewModel.createSections(from: filteredBlockTypes) - self.filteredSections = allSections - - setupSearchObserver() - } - - private func setupSearchObserver() { - $searchText - .debounce(for: .milliseconds(300), scheduler: RunLoop.main) - .sink { [weak self] searchText in - self?.updateFilteredSections(searchText: searchText) - } - .store(in: &cancellables) - } - - private var cancellables = Set() - - private func updateFilteredSections(searchText: String) { - if searchText.isEmpty { - filteredSections = allSections - } else { - filteredSections = allSections.compactMap { section in - let filtered = filterBlocks(in: section, searchText: searchText) - return filtered.isEmpty ? nil : BlockInserterSection( - category: section.category, - name: section.name, - blockTypes: filtered - ) - } - } - } - - private func filterBlocks(in section: BlockInserterSection, searchText: String) -> [EditorBlockType] { - let searchEngine = SearchEngine() - let filtered = searchEngine.search(query: searchText, in: section.blockTypes) - - // Maintain paragraph first in text category when showing all blocks - if searchText.isEmpty && section.name == "Text" && filtered.count > 1 { - return sortTextBlocks(filtered) - } - - return filtered - } - - private func sortTextBlocks(_ blocks: [EditorBlockType]) -> [EditorBlockType] { - let paragraphBlocks = blocks.filter { $0.name == "core/paragraph" } - let otherBlocks = blocks.filter { $0.name != "core/paragraph" } - return paragraphBlocks + otherBlocks - } - - private static func createSections(from blockTypes: [EditorBlockType]) -> [BlockInserterSection] { - let categoryOrder = BlockInserterConstants.categoryOrder - var grouped = Dictionary(grouping: blockTypes) { $0.category?.lowercased() ?? "common" } - - // Move core/embed from embed category to media category - if let embedBlocks = grouped["embed"], - let embedBlock = embedBlocks.first(where: { $0.name == "core/embed" }) { - // Add to media category - if var mediaBlocks = grouped["media"] { - mediaBlocks.append(embedBlock) - grouped["media"] = mediaBlocks - } else { - grouped["media"] = [embedBlock] - } - - // Remove from embed category - grouped["embed"] = embedBlocks.filter { $0.name != "core/embed" } - if grouped["embed"]?.isEmpty == true { - grouped.removeValue(forKey: "embed") - } - } - - var sections: [BlockInserterSection] = [] - - // Add sections in WordPress standard order - for (categoryKey, displayName) in categoryOrder { - if let blocks = grouped[categoryKey] { - let sortedBlocks = sortBlocks(blocks, category: categoryKey) - sections.append(BlockInserterSection(category: categoryKey, name: displayName, blockTypes: sortedBlocks)) - } - } - - // Add any remaining categories - for (category, blocks) in grouped { - let isStandardCategory = categoryOrder.contains { $0.key == category } - if !isStandardCategory { - sections.append(BlockInserterSection(category: category, name: category.capitalized, blockTypes: blocks)) - } - } - - return sections - } - - private static func sortBlocks(_ blocks: [EditorBlockType], category: String) -> [EditorBlockType] { - switch category { - case "text": - return sortWithOrder(blocks, order: BlockInserterConstants.textBlockOrder) - case "media": - return sortWithOrder(blocks, order: BlockInserterConstants.mediaBlockOrder) - case "design": - return sortWithOrder(blocks, order: BlockInserterConstants.designBlockOrder) - default: - return blocks - } - } - - private static func sortWithOrder(_ blocks: [EditorBlockType], order: [String]) -> [EditorBlockType] { - var orderedBlocks: [EditorBlockType] = [] - - // Add blocks in defined order - for blockName in order { - if let block = blocks.first(where: { $0.name == blockName }) { - orderedBlocks.append(block) - } - } - - // Add remaining blocks in their original order - let remainingBlocks = blocks.filter { block in - !order.contains(block.name) - } - - return orderedBlocks + remainingBlocks - } -} - -// MARK: - Constants - -enum BlockInserterConstants { - static let categoryOrder: [(key: String, displayName: String)] = [ - ("text", "Text"), - ("media", "Media"), - ("design", "Design"), - ("widgets", "Widgets"), - ("theme", "Theme"), - ("embed", "Embeds") - ] - - static let textBlockOrder = [ - "core/paragraph", - "core/heading", - "core/list", - "core/list-item", - "core/quote", - "core/code", - "core/preformatted", - "core/verse", - "core/table" - ] - - static let mediaBlockOrder = [ - "core/image", - "core/video", - "core/gallery", - "core/embed", - "core/audio", - "core/file" - ] - - static let designBlockOrder = [ - "core/separator", - "core/spacer", - "core/columns", - "core/column" - ] -} - -// MARK: - Extensions - -// MARK: - Supporting Types - -struct BlockInserterSection: Identifiable { - var id: String { category } - let category: String - let name: String - let blockTypes: [EditorBlockType] -} - -// MARK: - Preview - -import Combine - -struct BlockInserterView_Previews: PreviewProvider { - static var previews: some View { - SheetPreviewContainer() - } -} - -struct SheetPreviewContainer: View { - @State private var isShowingSheet = true - - var body: some View { - Button("Show Block Inserter") { - isShowingSheet = true - } - .popover(isPresented: $isShowingSheet) { - BlockInserterView( - blockTypes: PreviewData.sampleBlockTypes, - onBlockSelected: { blockType in - print("Selected block: \(blockType.name)") - } - ) - } - } -} diff --git a/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift b/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift index 60568474..9c89230d 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift @@ -53,6 +53,6 @@ struct EditorJSMessage { } struct ShowBlockPickerBody: Decodable { - let blockTypes: [EditorBlockType] + let blockTypes: [EditorBlock] } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorTypes.swift b/ios/Sources/GutenbergKit/Sources/EditorTypes.swift index f209ae89..24bdd1ab 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorTypes.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorTypes.swift @@ -1,6 +1,6 @@ import Foundation -struct EditorBlockType: Decodable, Identifiable { +struct EditorBlock: Decodable, Identifiable { var id: String { name } let name: String diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 8a42d98c..1058b512 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -3,11 +3,13 @@ import UIKit import SwiftUI import Combine import CryptoKit +import PhotosUI @MainActor public final class EditorViewController: UIViewController, GutenbergEditorControllerDelegate { public let webView: WKWebView let assetsLibrary: EditorAssetsLibrary + let fileManager = EditorFileManager.shared public var configuration: EditorConfiguration private var _isEditorRendered = false @@ -47,6 +49,10 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro for scheme in CachedAssetSchemeHandler.supportedURLSchemes { config.setURLSchemeHandler(schemeHandler, forURLScheme: scheme) } + + // Register local media scheme handler + let localMediaHandler = EditorFileSchemeHandler() + config.setURLSchemeHandler(localMediaHandler, forURLScheme: EditorFileSchemeHandler.scheme) self.webView = GBWebView(frame: .zero, configuration: config) self.webView.scrollView.keyboardDismissMode = .interactive @@ -84,33 +90,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro setUpEditor() loadEditor() } - - // TODO: register it when editor is loaded -// service.$rawBlockTypesResponseData.compactMap({ $0 }).sink { [weak self] data in -// guard let self else { return } -// assert(Thread.isMainThread) -// -// }.store(in: &cancellables) - } - - // TODO: move - private func registerBlockTypes(data: Data) async { - guard let string = String(data: data, encoding: .utf8), - let escapedString = string.addingPercentEncoding(withAllowedCharacters: .alphanumerics) else { - assertionFailure("invalid block types") - return - } - do { - // TODO: simplify this - try await webView.evaluateJavaScript(""" - const blockTypes = JSON.parse(decodeURIComponent('\(escapedString)')); - editor.registerBlocks(blockTypes); - "done"; - """) - } catch { - NSLog("failed to register blocks \(error)") - // TOOD: relay to the client - } } private func setUpEditor() { @@ -184,7 +163,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro content: '\(escapedContent)' }, }; - + localStorage.setItem('GBKit', JSON.stringify(window.GBKit)); "done"; @@ -197,7 +176,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // MARK: - Public API // TODO: synchronize with the editor user-generated updates - // TODO: convert to a property? public func setContent(_ content: String) { _setContent(content) } @@ -280,36 +258,64 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // MARK: - Internal (Block Inserter) - private func showBlockInserter(blockTypes: [EditorBlockType]) { - let view = BlockInserterView(blockTypes: blockTypes) { [weak self] selectedBlockType in - self?.insertBlock(selectedBlockType) - } - let host = UIHostingController(rootView: view) + private func showBlockInserter(blocks: [EditorBlock]) { + let parameters = MediaPickerParameters(filter: .all, isMultipleSelectionEnabled: true) + let mediaPicker = delegate?.getMediaPickerController(for: self, parameters: parameters) + + let host = UIHostingController(rootView: AnyView(EmptyView())) + + let view = BlockInserterView( + blocks: blocks, + mediaPicker: mediaPicker, + presentingViewController: host, + onBlockSelected: { [weak self] in + self?.insertBlock($0) + }, + onMediaSelected: { [weak self] in + self?.insertMedia($0) + } + ) + + host.rootView = AnyView(NavigationStack { view }) host.view.backgroundColor = .clear // Configure sheet presentation with medium detent if let sheet = host.sheetPresentationController { let compactHeight: CGFloat if #available(iOS 26, *) { - compactHeight = 548 + compactHeight = 512 } else { - compactHeight = 566 + compactHeight = 528 } sheet.detents = [.custom(identifier: .medium, resolver: { context in context.containerTraitCollection.horizontalSizeClass == .compact ? compactHeight : 900 }), .large()] sheet.prefersGrabberVisible = true - sheet.preferredCornerRadius = 20 - sheet.prefersScrollingExpandsWhenScrolledToEdge = true + sheet.preferredCornerRadius = 26 } present(host, animated: true) } - private func insertBlock(_ blockType: EditorBlockType) { + private func insertBlock(_ blockType: EditorBlock) { evaluate("window.editor.insertBlock('\(blockType.name)');") } + + private func insertMedia(_ mediaInfo: [MediaInfo]) { + guard !mediaInfo.isEmpty else { return } + + // Encode MediaInfo array directly since it's Codable + do { + let encoder = JSONEncoder() + let jsonData = try encoder.encode(mediaInfo) + if let jsonString = String(data: jsonData, encoding: .utf8) { + evaluate("window.editor.insertMediaFromFiles(\(jsonString));") + } + } catch { + assertionFailure("Failed to serialize media items: \(error)") + } + } private func openMediaLibrary(_ config: OpenMediaLibraryAction) { delegate?.editor(self, didRequestMediaFromSiteMediaLibrary: config) @@ -322,7 +328,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // MARK: - GutenbergEditorControllerDelegate fileprivate func controller(_ controller: GutenbergEditorController, didReceiveMessage message: EditorJSMessage) { - print("Received message type: \(message.type)") do { switch message.type { case .onEditorLoaded: @@ -347,9 +352,9 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro case .showBlockPicker: do { let body = try message.decode(EditorJSMessage.ShowBlockPickerBody.self) - showBlockInserter(blockTypes: body.blockTypes) + showBlockInserter(blocks: body.blockTypes) } catch { - showBlockInserter(blockTypes: []) + showBlockInserter(blocks: []) } case .openMediaLibrary: let config = try message.decode(OpenMediaLibraryAction.self) @@ -372,46 +377,22 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro let duration = CFAbsoluteTimeGetCurrent() - timestampInit print("gutenbergkit-measure_editor-first-render:", duration) delegate?.editorDidLoad(self) - - // Auto-focus the editor after it loads if configured - if configuration.autoFocusOnLoad { - autoFocusEditor() + + if configuration.autoFocusOnLoad, configuration.content.isEmpty { + self.autoFocusEditor() } } private func autoFocusEditor() { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.simulateTapOnWebView() - } - } - - private func simulateTapOnWebView() { - // Use a hidden text field to trigger keyboard, then transfer focus - let hiddenTextField = UITextField(frame: CGRect(x: -100, y: -100, width: 1, height: 1)) - hiddenTextField.autocorrectionType = .no - hiddenTextField.autocapitalizationType = .none - view.addSubview(hiddenTextField) - - // Focus the hidden field to bring up keyboard - hiddenTextField.becomeFirstResponder() - - // After a short delay, transfer focus to web view - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - hiddenTextField.removeFromSuperview() - self?.webView.becomeFirstResponder() - - // Try one more JavaScript focus attempt with keyboard already up - let focusScript = """ - (function() { - const editable = document.querySelector('[contenteditable="true"]'); - if (editable) { - editable.focus(); - editable.click(); - } - })(); - """ - self?.evaluate(focusScript) - } + evaluate(""" + (function() { + const editable = document.querySelector('[contenteditable="true"]'); + if (editable) { + editable.focus(); + editable.click(); + } + })(); + """) } // MARK: - Warmup @@ -629,3 +610,7 @@ class CachedAssetSchemeHandler: NSObject, WKURLSchemeHandler { } } } + +// MARK: - LocalMediaSchemeHandler + + diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift index d8a87c84..98b239a0 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift @@ -34,6 +34,10 @@ public protocol EditorViewControllerDelegate: AnyObject { func editor(_ viewController: EditorViewController, didLogException error: GutenbergJSException) func editor(_ viewController: EditorViewController, didRequestMediaFromSiteMediaLibrary config: OpenMediaLibraryAction) + + /// Returns the available media picker sources for the given configuration. + /// The controller is retained by the editor. + func getMediaPickerController(for viewController: EditorViewController, parameters: MediaPickerParameters) -> (any MediaPickerController)? } public struct EditorState { @@ -132,27 +136,3 @@ public struct OpenMediaLibraryAction: Codable { case multiple([Int]) } } - -public struct MediaInfo: Codable { - public let id: Int32? - public let url: String? - public let type: String? - public let title: String? - public let caption: String? - public let alt: String? - public let metadata: [String: String] - - private enum CodingKeys: String, CodingKey { - case id, url, type, title, caption, alt, metadata - } - - public init(id: Int32?, url: String?, type: String?, caption: String? = nil, title: String? = nil, alt: String? = nil, metadata: [String: String] = [:]) { - self.id = id - self.url = url - self.type = type - self.caption = caption - self.title = title - self.alt = alt - self.metadata = metadata - } -} diff --git a/ios/Sources/GutenbergKit/Sources/Helpers/EditorFileManager.swift b/ios/Sources/GutenbergKit/Sources/Helpers/EditorFileManager.swift new file mode 100644 index 00000000..879f8304 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Helpers/EditorFileManager.swift @@ -0,0 +1,94 @@ +import Foundation +import UniformTypeIdentifiers + +/// Manages editor files and media uploads in the Documents directory +actor EditorFileManager { + static let shared = EditorFileManager() + + private let fileManager = FileManager.default + + /// Base directory for all GutenbergKit files + let rootURL: URL + + /// Uploads directory URL + private let uploadsDirectory: URL + + init(rootURL: URL? = nil) { + if let rootURL { + self.rootURL = rootURL + } else { + self.rootURL = URL.libraryDirectory.appendingPathComponent("GutenbergKit") + } + self.uploadsDirectory = self.rootURL.appendingPathComponent("Uploads") + + // Create directories synchronously during init + try? fileManager.createDirectory(at: self.rootURL, withIntermediateDirectories: true) + try? fileManager.createDirectory(at: self.uploadsDirectory, withIntermediateDirectories: true) + + // Schedule cleanup of old files + Task { + await cleanupOldFiles() + } + } + + /// Saves media data to the uploads directory and returns its custom scheme URL + func saveMediaData(_ data: Data, withExtension ext: String) async throws -> URL { + let fileName = "\(UUID().uuidString).\(ext)" + let destinationURL = uploadsDirectory.appendingPathComponent(fileName) + + try data.write(to: destinationURL) + + // Return custom scheme URL: gbk-file:///Uploads/filename.ext + return URL(string: "\(EditorFileSchemeHandler.scheme):///Uploads/\(fileName)")! + } + + /// Gets URLResponse and data for a gbk-file URL + func getResponse(for url: URL) async throws -> (URLResponse, Data) { + // Convert `gbk-file:///Uploads/filename.jpg` to actual file path + let path = url.path + let fileURL = rootURL.appendingPathComponent(path) + + let data = try Data(contentsOf: fileURL) + + let headers = [ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", + "Access-Control-Allow-Headers": "*", + "Cache-Control": "no-cache" + ] + + guard let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: headers + ) else { + throw URLError(.unknown) + } + + return (response, data) + } + + /// Cleans up files older than 2 days + private func cleanupOldFiles() { + let sevenDaysAgo = Date().addingTimeInterval(-2 * 24 * 60 * 60) + + do { + let contents = try fileManager.contentsOfDirectory( + at: uploadsDirectory, + includingPropertiesForKeys: [.creationDateKey], + options: .skipsHiddenFiles + ) + + for fileURL in contents { + if let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path), + let creationDate = attributes[.creationDate] as? Date, + creationDate < sevenDaysAgo { + try? fileManager.removeItem(at: fileURL) + } + } + } catch { + print("Failed to clean up old files: \(error)") + } + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Helpers/EditorFileSchemeHandler.swift b/ios/Sources/GutenbergKit/Sources/Helpers/EditorFileSchemeHandler.swift new file mode 100644 index 00000000..3e851eed --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Helpers/EditorFileSchemeHandler.swift @@ -0,0 +1,29 @@ +import WebKit + +class EditorFileSchemeHandler: NSObject, WKURLSchemeHandler { + nonisolated static let scheme = "gbk-file" + + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + guard let url = urlSchemeTask.request.url else { + urlSchemeTask.didFailWithError(URLError(.badURL)) + return + } + Task { + do { + let fileManager = EditorFileManager.shared + let (response, data) = try await fileManager.getResponse(for: url) + + // Send response and data + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } catch { + urlSchemeTask.didFailWithError(error) + } + } + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { + // Nothing to do here for simple file serving + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaInfo.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaInfo.swift new file mode 100644 index 00000000..e86a1419 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaInfo.swift @@ -0,0 +1,25 @@ +import Foundation + +public struct MediaInfo: Codable { + public let id: Int32? + public let url: String? + public let type: String? + public let title: String? + public let caption: String? + public let alt: String? + public let metadata: [String: String] + + private enum CodingKeys: String, CodingKey { + case id, url, type, title, caption, alt, metadata + } + + public init(id: Int32? = nil, url: String?, type: String?, caption: String? = nil, title: String? = nil, alt: String? = nil, metadata: [String: String] = [:]) { + self.id = id + self.url = url + self.type = type + self.caption = caption + self.title = title + self.alt = alt + self.metadata = metadata + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Media/MediaPickerAction.swift b/ios/Sources/GutenbergKit/Sources/Media/MediaPickerAction.swift new file mode 100644 index 00000000..90940cc9 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Media/MediaPickerAction.swift @@ -0,0 +1,37 @@ +import UIKit + +public struct MediaPickerAction: Identifiable { + public let id: String + public let title: String + public let image: UIImage + public let perform: @MainActor (UIViewController, @escaping ([MediaInfo]) -> Void) -> Void + + public init(id: String, title: String, image: UIImage, perform: @escaping @MainActor (UIViewController, @escaping ([MediaInfo]) -> Void) -> Void) { + self.id = id + self.title = title + self.image = image + self.perform = perform + } +} + +public struct MediaPickerParameters { + public enum MediaFilter { + case images + case videos + case all + } + + public var filter: MediaFilter? + public var isMultipleSelectionEnabled: Bool + + public init(filter: MediaFilter? = nil, isMultipleSelectionEnabled: Bool = false) { + self.filter = filter + self.isMultipleSelectionEnabled = isMultipleSelectionEnabled + } +} + +/// A type that manages media picker sources. +public protocol MediaPickerController { + /// A grouped list of available actions. + var actions: [[MediaPickerAction]] { get } +} diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterBlockView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterBlockView.swift new file mode 100644 index 00000000..3b46c93e --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterBlockView.swift @@ -0,0 +1,109 @@ +import SwiftUI + +struct BlockInserterBlockView: View { + let block: EditorBlock + let action: () -> Void + + @State private var isPressed = false + @State private var isHovered = false + + @ScaledMetric(relativeTo: .largeTitle) private var iconSize = 44 + + var body: some View { + Button(action: { + onSelected() + }) { + VStack(spacing: 8) { + BlockIconView(block: block, size: iconSize) + Text(title) + .font(.caption) + .lineLimit(2, reservesSpace: true) + .multilineTextAlignment(.center) + } + .foregroundStyle(Color.primary) + .scaleEffect(isPressed ? 0.9 : 1.0) + .animation(.spring, value: isPressed) + .padding(.horizontal, 4) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity, alignment: .center) + .contextMenu { + Button { + onSelected() + } label: { + Label(title, systemImage: "plus") + } + } preview: { + BlockDetailedView(block: block) + } + } + + private func onSelected() { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + isPressed = true + action() + } + + private var title: String { + block.title ?? block.name + .split(separator: "/") + .last + .map(String.init) ?? "-" + } +} + +private struct BlockIconView: View { + let block: EditorBlock + let size: CGFloat + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(Color(uiColor: .secondarySystemFill)) + .frame(width: size, height: size) + .shadow(color: Color.black.opacity(0.1), radius: 2, x: 0, y: 1) + + Image(systemName: block.iconName) + .font(.system(size: size * 0.5)) + .foregroundColor(.primary) + .symbolRenderingMode(.hierarchical) + } + } +} + +private struct BlockDetailedView: View { + let block: EditorBlock + + var body: some View { + HStack(spacing: 16) { + BlockIconView(block: block, size: 56) + + VStack(alignment: .leading, spacing: 2) { + if let title = block.title { + Text(title) + .font(.headline) + .foregroundColor(.primary) + Text(block.name) + .font(.footnote) + .foregroundColor(.secondary) + } else { + Text(block.name) + .font(.headline) + .foregroundColor(.primary) + } + + if let description = block.description { + Text(description) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(4) + .padding(.top, 8) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(16) + .frame(width: 360) + .background(Color(uiColor: .systemBackground)) + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift new file mode 100644 index 00000000..c28e826b --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterSectionView.swift @@ -0,0 +1,49 @@ +import SwiftUI +import PhotosUI + +struct BlockInserterSectionView: View { + let section: BlockInserterSection + let onBlockSelected: (EditorBlock) -> Void + let onMediaSelected: ([PhotosPickerItem]) -> Void + + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + @ScaledMetric(relativeTo: .largeTitle) private var miniumSize = 80 + + init( + section: BlockInserterSection, + onBlockSelected: @escaping (EditorBlock) -> Void, + onMediaSelected: @escaping ([PhotosPickerItem]) -> Void + ) { + self.section = section + self.onBlockSelected = onBlockSelected + self.onMediaSelected = onMediaSelected + } + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + if section.category != "text" { + Text(section.name) + .font(.headline) + .foregroundStyle(Color.secondary) + .padding(.leading, 20) + .frame(maxWidth: .infinity, alignment: .leading) + } + grid + } + .padding(.top, section.category != "text" ? 20 : 24) + .padding(.bottom, 10) + .cardStyle() + } + + private var grid: some View { + LazyVGrid(columns: [GridItem(.adaptive(minimum: miniumSize, maximum: miniumSize + 40), spacing: 0)]) { + ForEach(section.blocks) { block in + BlockInserterBlockView(block: block) { + onBlockSelected(block) + } + } + } + .padding(.horizontal, 12) + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift new file mode 100644 index 00000000..f2a05573 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift @@ -0,0 +1,243 @@ +import SwiftUI +import PhotosUI +import UIKit + +struct BlockInserterView: View { + let mediaPicker: MediaPickerController? + weak var presentingViewController: UIViewController? + let onBlockSelected: (EditorBlock) -> Void + let onMediaSelected: ([MediaInfo]) -> Void + + @StateObject private var viewModel: BlockInserterViewModel + + @State private var selectedMediaItems: [PhotosPickerItem] = [] + @State private var inlineSelectedMediaItems: [PhotosPickerItem] = [] + @State private var isShowingCamera = false + + @ScaledMetric(relativeTo: .largeTitle) private var inlinePickerHeight = 86 + + private let maxSelectionCount = 10 + + @Environment(\.dismiss) private var dismiss + + init( + blocks: [EditorBlock], + mediaPicker: MediaPickerController?, + presentingViewController: UIViewController? = nil, + onBlockSelected: @escaping (EditorBlock) -> Void, + onMediaSelected: @escaping ([MediaInfo]) -> Void + ) { + self.mediaPicker = mediaPicker + self.presentingViewController = presentingViewController + self.onBlockSelected = onBlockSelected + self.onMediaSelected = onMediaSelected + + self._viewModel = StateObject(wrappedValue: BlockInserterViewModel(blocks: blocks)) + } + + var body: some View { + content + .background(Material.ultraThin) + .searchable(text: $viewModel.searchText) + .navigationBarTitleDisplayMode(.inline) + .disabled(viewModel.isProcessingMedia) + // In most cases, processing is nearly instant – we don't want any jarring changes + .animation(.smooth(duration: 2), value: viewModel.isProcessingMedia) + .toolbar { toolbarContent } + .fullScreenCover(isPresented: $isShowingCamera) { + CameraView { insertMedia($0) } + .ignoresSafeArea() + } + .alert(item: $viewModel.error) { error in + Alert(title: Text(error.message)) + } + .onDisappear { + if viewModel.isProcessingMedia { + viewModel.cancelProcessing() + } + } + } + + private var content: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + if #available(iOS 17, *) { + if viewModel.searchText.isEmpty { + inlinePhotosPickerSection + } + } + ForEach(viewModel.sections) { section in + BlockInserterSectionView( + section: section, + onBlockSelected: insertBlock, + onMediaSelected: insertMedia + ) + .padding(.horizontal) + } + } + .padding(.vertical, 8) + .dynamicTypeSize(...(.accessibility3)) + } + .scrollContentBackground(.hidden) + .animation(.snappy, value: inlineSelectedMediaItems.count) + } + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + .tint(Color.primary) + } + ToolbarItemGroup(placement: .automatic) { + mediaToolbarButtons + .tint(Color.primary) + .disabled(viewModel.isProcessingMedia) + } + } + + @ViewBuilder + private var mediaToolbarButtons: some View { + PhotosPicker(selection: $selectedMediaItems) { + Image(systemName: "photo.on.rectangle.angled") + } + .onChange(of: selectedMediaItems) { + insertMedia($0) + selectedMediaItems = [] + } + + Button(action: { + isShowingCamera = true + }) { + Image(systemName: "camera") + } + + if let groups = mediaPicker?.actions, !groups.isEmpty { + Menu { + ForEach(groups.indices, id: \.self) { groupIndex in + Section { + ForEach(groups[groupIndex]) { picker in + Button(action: { + if let presentingViewController { + picker.perform(presentingViewController) { mediaInfo in + self.onMediaSelected(mediaInfo) + self.dismiss() + } + } + }) { + Label { + Text(picker.title) + } icon: { + Image(uiImage: picker.image) + } + } + } + } + } + } label: { + Image(systemName: "ellipsis") + } + } + } + + // MARK: - Inline PhotosPicker (.compact) + + @available(iOS 17, *) + @ViewBuilder + private var inlinePhotosPickerSection: some View { + PhotosPicker( + "", + selection: $inlineSelectedMediaItems, + maxSelectionCount: maxSelectionCount, + selectionBehavior: .continuousAndOrdered + ) + .photosPickerStyle(.compact) + .photosPickerDisabledCapabilities([.collectionNavigation, .search, .sensitivityAnalysisIntervention, .stagingArea]) + .photosPickerAccessoryVisibility(.hidden) + .frame(height: inlinePickerHeight) + .clipShape(UnevenRoundedRectangle(topLeadingRadius: 22, bottomLeadingRadius: 22, bottomTrailingRadius: 0, topTrailingRadius: 0)) + .padding(.leading) + .opacity(viewModel.isProcessingMedia ? 0.5 : 1.0) + .transition(.asymmetric( + insertion: .scale(scale: 0.95, anchor: .leading).combined(with: .opacity), + removal: .scale(scale: 0.95, anchor: .leading).combined(with: .opacity) + )) + .animation(.spring(response: 0.5, dampingFraction: 0.7), value: viewModel.searchText.isEmpty) + + if !inlineSelectedMediaItems.isEmpty { + Button { + insertMedia(inlineSelectedMediaItems) + inlineSelectedMediaItems = [] + } label: { + // Making the best of it without using any localizable strings + Image(systemName: "plus") + // Setting max 1 to to prevent it from animating to 0 on disappear + Text("\(max(1, inlineSelectedMediaItems.count))") + .contentTransition(.numericText()) + } + .font(.system(.headline, design: .rounded)) + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .tint(.primary) + // It animates as if it was hidden behind the next section + .transition(.offset(y: 28).combined(with: .scale(scale: 0.85))) + .frame(maxWidth: .infinity, alignment: .center) + } + } + + // MARK: - Actions + + private func insertBlock(_ blockType: EditorBlock) { + dismiss() + onBlockSelected(blockType) + } + + private func insertMedia(_ items: [PhotosPickerItem]) { + Task { + await viewModel.processMediaItems(items) { mediaInfo in + dismiss() + onMediaSelected(mediaInfo) + } + } + } + + private func insertMedia(_ media: CameraMedia) { + Task { + await viewModel.processCameraMedia(media) { mediaInfo in + dismiss() + onMediaSelected(mediaInfo) + } + } + } +} + +// MARK: - Preview + +#if DEBUG +#Preview { + NavigationStack { + BlockInserterView( + blocks: PreviewData.sampleBlockTypes, + mediaPicker: MockMediaPickerController(), + onBlockSelected: { blockType in + print("Selected block: \(blockType.name)") + }, + onMediaSelected: { mediaInfo in + print("Selected \(mediaInfo.count) media items") + } + ) + } +} + +struct MockMediaPickerController: MediaPickerController { + let actions: [[MediaPickerAction]] = [[ + MediaPickerAction(id: "files", title: "Files", image: UIImage(systemName: "folder")!) { _, completion in + print("Files tapped") + completion([]) + } + ]] +} +#endif diff --git a/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift new file mode 100644 index 00000000..b54f9128 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift @@ -0,0 +1,330 @@ +import SwiftUI +import PhotosUI +import Combine + +@MainActor +class BlockInserterViewModel: ObservableObject { + @Published var searchText = "" + @Published var error: MediaError? + @Published private(set) var sections: [BlockInserterSection] = [] + @Published private(set) var isProcessingMedia = false + + private let blocks: [EditorBlock] + private let allSections: [BlockInserterSection] + private let fileManager: EditorFileManager + private var processingTask: Task? + private var cancellables = Set() + + struct MediaError: Identifiable { + let id = UUID() + let message: String + } + + init(blocks: [EditorBlock], fileManager: EditorFileManager = .shared) { + let blocks = blocks.filter { $0.name != "core/missing" } + + self.blocks = blocks + self.fileManager = fileManager + + self.allSections = BlockInserterViewModel.createSections(from: blocks) + self.sections = allSections + + setupSearchObserver() + } + + private func setupSearchObserver() { + $searchText + .debounce(for: .milliseconds(200), scheduler: RunLoop.main) + .sink { [weak self] searchText in + self?.updateFilteredSections(searchText: searchText) + } + .store(in: &cancellables) + } + + private func updateFilteredSections(searchText: String) { + if searchText.isEmpty { + sections = allSections + } else { + sections = allSections.compactMap { section in + let filtered = filterBlocks(in: section, searchText: searchText) + return filtered.isEmpty ? nil : BlockInserterSection( + category: section.category, + name: section.name, + blocks: filtered + ) + } + } + } + + private func filterBlocks(in section: BlockInserterSection, searchText: String) -> [EditorBlock] { + let searchEngine = SearchEngine() + let filtered = searchEngine.search(query: searchText, in: section.blocks) + + // Maintain paragraph first in text category when showing all blocks + if searchText.isEmpty && section.name == "Text" && filtered.count > 1 { + return sortTextBlocks(filtered) + } + + return filtered + } + + private func sortTextBlocks(_ blocks: [EditorBlock]) -> [EditorBlock] { + let paragraphBlocks = blocks.filter { $0.name == "core/paragraph" } + let otherBlocks = blocks.filter { $0.name != "core/paragraph" } + return paragraphBlocks + otherBlocks + } + + private static func createSections(from blockTypes: [EditorBlock]) -> [BlockInserterSection] { + let categoryOrder = BlockInserterConstants.categoryOrder + var grouped = Dictionary(grouping: blockTypes) { $0.category?.lowercased() ?? "common" } + + // Move core/embed from embed category to media category + if let embedBlocks = grouped["embed"], + let embedBlock = embedBlocks.first(where: { $0.name == "core/embed" }) { + // Add to media category + if var mediaBlocks = grouped["media"] { + mediaBlocks.append(embedBlock) + grouped["media"] = mediaBlocks + } else { + grouped["media"] = [embedBlock] + } + + // Remove from embed category + grouped["embed"] = embedBlocks.filter { $0.name != "core/embed" } + if grouped["embed"]?.isEmpty == true { + grouped.removeValue(forKey: "embed") + } + } + + var sections: [BlockInserterSection] = [] + + // Add sections in WordPress standard order + for (categoryKey, displayName) in categoryOrder { + if let blocks = grouped[categoryKey] { + let sortedBlocks = sortBlocks(blocks, category: categoryKey) + sections.append(BlockInserterSection(category: categoryKey, name: displayName, blocks: sortedBlocks)) + } + } + + // Add any remaining categories + for (category, blocks) in grouped { + let isStandardCategory = categoryOrder.contains { $0.key == category } + if !isStandardCategory { + sections.append(BlockInserterSection(category: category, name: category.capitalized, blocks: blocks)) + } + } + + return sections + } + + private static func sortBlocks(_ blocks: [EditorBlock], category: String) -> [EditorBlock] { + switch category { + case "text": + return sortWithOrder(blocks, order: BlockInserterConstants.textBlockOrder) + case "media": + return sortWithOrder(blocks, order: BlockInserterConstants.mediaBlockOrder) + case "design": + return sortWithOrder(blocks, order: BlockInserterConstants.designBlockOrder) + default: + return blocks + } + } + + private static func sortWithOrder(_ blocks: [EditorBlock], order: [String]) -> [EditorBlock] { + var orderedBlocks: [EditorBlock] = [] + + // Add blocks in defined order + for blockName in order { + if let block = blocks.first(where: { $0.name == blockName }) { + orderedBlocks.append(block) + } + } + + // Add remaining blocks in their original order + let remainingBlocks = blocks.filter { block in + !order.contains(block.name) + } + + return orderedBlocks + remainingBlocks + } + + // MARK: - Media Processing + + func processMediaItems(_ items: [PhotosPickerItem], completion: @escaping ([MediaInfo]) -> Void) async { + isProcessingMedia = true + defer { isProcessingMedia = false } + + var lastError: Error? + + // Store the task so it can be cancelled + processingTask = Task { @MainActor in + // Process all items in parallel + let results = await withTaskGroup(of: MediaInfo?.self) { group in + for item in items { + group.addTask { + // Check for cancellation + if Task.isCancelled { return nil } + + // Load the media data + guard let data = try? await item.loadTransferable(type: Data.self) else { + return nil + } + + // Determine file extension + let contentType = item.supportedContentTypes.first + let fileExtension = contentType?.preferredFilenameExtension ?? "jpg" + + do { + // Save to uploads directory - returns relative URL + let fileURL = try await self.fileManager.saveMediaData(data, withExtension: fileExtension) + + // Determine media type based on content type + let mediaType: String + if contentType?.conforms(to: .image) == true { + mediaType = "image" + } else if contentType?.conforms(to: .movie) == true { + mediaType = "video" + } else if contentType?.conforms(to: .audio) == true { + mediaType = "audio" + } else { + mediaType = "file" + } + + // Create MediaInfo object + return MediaInfo(url: fileURL.absoluteString, type: mediaType) + } catch { + print("Failed to save media file: \(error)") + lastError = error + return nil + } + } + } + + var output: [MediaInfo] = [] + for await result in group { + if let mediaInfo = result { + output.append(mediaInfo) + } + } + return output + } + + // Only call completion if not cancelled + if !Task.isCancelled { + // Show error if we encountered any errors and got no successful results + if let error = lastError, results.isEmpty { + self.error = MediaError(message: error.localizedDescription) + } + + // Still return successfully processed items + completion(results) + } + } + + await processingTask?.value + } + + func processCameraMedia(_ media: CameraMedia, completion: @escaping ([MediaInfo]) -> Void) async { + isProcessingMedia = true + defer { isProcessingMedia = false } + + processingTask = Task { @MainActor in + do { + // Check for cancellation + if Task.isCancelled { return } + + let mediaInfo: MediaInfo + + switch media { + case .photo(let image): + guard let imageData = image.jpegData(compressionQuality: 0.8) else { + return // Should never happen + } + + // Save to uploads directory - returns relative URL + let fileURL = try await fileManager.saveMediaData(imageData, withExtension: "jpg") + + // Create MediaInfo for the captured image + mediaInfo = MediaInfo(url: fileURL.absoluteString, type: "image") + + case .video(let videoURL): + // Copy video to uploads directory + let videoData = try Data(contentsOf: videoURL) + let fileExtension = videoURL.pathExtension.isEmpty ? "mp4" : videoURL.pathExtension + + // Save to uploads directory - returns relative URL + let fileURL = try await fileManager.saveMediaData(videoData, withExtension: fileExtension) + + // Create MediaInfo for the captured video + mediaInfo = MediaInfo(url: fileURL.absoluteString, type: "video") + } + + // Only call completion if not cancelled + if !Task.isCancelled { + completion([mediaInfo]) + } + + } catch { + self.error = MediaError(message: error.localizedDescription) + } + } + + await processingTask?.value + } + + func cancelProcessing() { + processingTask?.cancel() + processingTask = nil + isProcessingMedia = false + } +} + +// MARK: - Constants + +enum BlockInserterConstants { + static let categoryOrder: [(key: String, displayName: String)] = [ + ("text", "Text"), + ("media", "Media"), + ("design", "Design"), + ("widgets", "Widgets"), + ("theme", "Theme"), + ("embed", "Embeds") + ] + + static let textBlockOrder = [ + "core/paragraph", + "core/heading", + "core/list", + "core/list-item", + "core/quote", + "core/code", + "core/preformatted", + "core/verse", + "core/table" + ] + + static let mediaBlockOrder = [ + "core/image", + "core/video", + "core/gallery", + "core/embed", + "core/audio", + "core/file" + ] + + static let designBlockOrder = [ + "core/separator", + "core/spacer", + "core/columns", + "core/column" + ] +} + +// MARK: - Supporting Types + +struct BlockInserterSection: Identifiable { + var id: String { category } + let category: String + let name: String + let blocks: [EditorBlock] +} diff --git a/ios/Sources/GutenbergKit/Sources/BlockInserter/EditorBlockType+Icons.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/EditorBlock+Icons.swift similarity index 68% rename from ios/Sources/GutenbergKit/Sources/BlockInserter/EditorBlockType+Icons.swift rename to ios/Sources/GutenbergKit/Sources/Views/BlockInserter/EditorBlock+Icons.swift index c7d6bd3c..12f8bd7e 100644 --- a/ios/Sources/GutenbergKit/Sources/BlockInserter/EditorBlockType+Icons.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/EditorBlock+Icons.swift @@ -1,6 +1,6 @@ import Foundation -extension EditorBlockType { +extension EditorBlock { /// Returns the SF Symbol icon name for the block type var iconName: String { switch name { @@ -8,13 +8,17 @@ extension EditorBlockType { case "core/paragraph": "paragraphsign" case "core/heading": "bookmark.fill" case "core/list": "list.bullet" + case "core/list-item": "list.bullet.indent" + case "core/details": "text.line.first.and.arrowtriangle.forward" case "core/quote": "quote.opening" case "core/code": "curlybraces" - case "core/preformatted": "text.alignleft" + case "core/preformatted": "text.word.spacing" case "core/pullquote": "quote.bubble" case "core/verse": "text.quote" case "core/table": "tablecells" - + case "core/footnotes": "list.number" + case "core/missing": "exclamationmark.triangle" + // MARK: - Core Media Blocks case "core/image": "photo" case "core/gallery": "photo.stack" @@ -25,13 +29,16 @@ extension EditorBlockType { case "core/video": "video" // MARK: - Core Design Blocks + case "core/button": "rectangle.fill" case "core/buttons": "rectangle.3.group" + case "core/column": "rectangle.ratio.9.to.16" case "core/columns": "rectangle.split.3x1" case "core/group": "square.on.square" case "core/more": "ellipsis" case "core/nextpage": "arrow.right.doc.on.clipboard" case "core/separator": "minus" case "core/spacer": "arrow.up.and.down" + case "core/text-columns": "text.justify.left" // MARK: - Core Widget Blocks case "core/archives": "archivebox" @@ -41,9 +48,11 @@ extension EditorBlockType { case "core/latest-comments": "bubble.left.and.bubble.right" case "core/latest-posts": "doc.plaintext" case "core/page-list": "list.bullet.rectangle" + case "core/page-list-item": "doc.text" case "core/rss": "dot.radiowaves.left.and.right" case "core/search": "magnifyingglass" case "core/shortcode": "curlybraces.square" + case "core/social-link": "link.circle" case "core/social-links": "person.2.circle" case "core/tag-cloud": "tag.circle" @@ -55,13 +64,51 @@ extension EditorBlockType { case "core/post-title": "doc.text" case "core/post-content": "doc.richtext" case "core/post-excerpt": "doc.append" - case "core/post-featured-image": "photo" + case "core/post-featured-image": "photo.circle" case "core/post-date": "calendar" case "core/post-author": "person.circle" case "core/post-comments": "bubble.left" case "core/post-navigation-link": "arrow.left.arrow.right" + case "core/post-author-name": "person.text.rectangle" + case "core/post-author-biography": "person.crop.square.filled.and.at.rectangle" + case "core/post-comments-count": "bubble.left.and.text.bubble.right" + case "core/post-comments-link": "bubble.left.and.bubble.right" + case "core/post-comments-form": "text.bubble" + case "core/post-terms": "tag" + case "core/post-template": "doc.on.doc" + case "core/avatar": "person.crop.circle" + case "core/navigation": "line.3.horizontal" + case "core/navigation-link": "link" + case "core/navigation-submenu": "chevron.down.square" + case "core/template-part": "square.split.2x2" + case "core/pattern": "square.grid.3x3.square" + case "core/block": "arrow.triangle.2.circlepath" + case "core/home-link": "house" + case "core/loginout": "person.crop.circle.badge.checkmark" + case "core/term-description": "text.book.closed" + case "core/query-title": "text.badge.checkmark" + case "core/query-pagination": "ellipsis.rectangle" + case "core/query-pagination-next": "chevron.right.square" + case "core/query-pagination-numbers": "number.square" + case "core/query-pagination-previous": "chevron.left.square" + case "core/query-no-results": "xmark.square" + case "core/query-total": "number.circle" + case "core/read-more": "arrow.right.circle" + case "core/comments": "bubble.left.and.bubble.right" + case "core/comment-author-name": "person.bubble" + case "core/comment-content": "text.bubble" + case "core/comment-date": "calendar.badge.clock" + case "core/comment-edit-link": "pencil.circle" + case "core/comment-reply-link": "arrowshape.turn.up.left" + case "core/comment-template": "bubble.left.and.text.bubble.right" + case "core/comments-title": "text.bubble.fill" + case "core/comments-pagination": "ellipsis.bubble" + case "core/comments-pagination-next": "chevron.right.bubble" + case "core/comments-pagination-numbers": "number.square.fill" + case "core/comments-pagination-previous": "chevron.left.bubble" // MARK: - Core Embed Blocks + case "core/embed": "chevron.left.forwardslash.chevron.right" case let name where name.hasPrefix("core-embed/"): "link.circle" // MARK: - Jetpack AI & Content @@ -86,7 +133,7 @@ extension EditorBlockType { // MARK: - Jetpack Media & Galleries case "jetpack/image-compare": "arrow.left.and.right" - case "jetpack/tiled-gallery": "square.grid.3x3" + case "jetpack/tiled-gallery": "rectangle.3.group" case "jetpack/slideshow": "play.rectangle" case "jetpack/story": "book.pages" case "jetpack/gif": "sparkles.rectangle.stack" diff --git a/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserter+PreviewData.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/EditorBlock+PreviewData.swift similarity index 90% rename from ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserter+PreviewData.swift rename to ios/Sources/GutenbergKit/Sources/Views/BlockInserter/EditorBlock+PreviewData.swift index 78f73936..70171fb8 100644 --- a/ios/Sources/GutenbergKit/Sources/BlockInserter/BlockInserter+PreviewData.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/EditorBlock+PreviewData.swift @@ -2,65 +2,65 @@ import Foundation enum PreviewData { - static let sampleBlockTypes: [EditorBlockType] = [ + static let sampleBlockTypes: [EditorBlock] = [ // Text blocks - EditorBlockType( + EditorBlock( name: "core/paragraph", title: "Paragraph", description: "Start with the basic building block of all narrative.", category: "text", keywords: ["text", "paragraph"], ), - EditorBlockType( + EditorBlock( name: "core/heading", title: "Heading", description: "Introduce new sections and organize content to help visitors find what they need.", category: "text", keywords: ["title", "heading"], ), - EditorBlockType( + EditorBlock( name: "core/list", title: "List", description: "Create a bulleted or numbered list.", category: "text", keywords: ["bullet", "number", "list"], ), - EditorBlockType( + EditorBlock( name: "core/quote", title: "Quote", description: "Give quoted text visual emphasis.", category: "text", keywords: ["quote", "citation"], ), - EditorBlockType( + EditorBlock( name: "core/code", title: "Code", description: "Display code snippets that respect your spacing and tabs.", category: "text", keywords: ["code", "programming"], ), - EditorBlockType( + EditorBlock( name: "core/preformatted", title: "Preformatted", description: "Add text that respects your spacing and tabs, and also allows styling.", category: "text", keywords: ["preformatted", "monospace"], ), - EditorBlockType( + EditorBlock( name: "core/pullquote", title: "Pullquote", description: "Give special visual emphasis to a quote from your text.", category: "text", keywords: ["pullquote", "quote"], ), - EditorBlockType( + EditorBlock( name: "core/verse", title: "Verse", description: "Insert poetry. Use special spacing formats. Or quote song lyrics.", category: "text", keywords: ["poetry", "verse"], ), - EditorBlockType( + EditorBlock( name: "core/table", title: "Table", description: "Create structured content in rows and columns to display information.", @@ -69,49 +69,49 @@ enum PreviewData { ), // Media blocks - EditorBlockType( + EditorBlock( name: "core/image", title: "Image", description: "Insert an image to make a visual statement.", category: "media", keywords: ["photo", "picture"], ), - EditorBlockType( + EditorBlock( name: "core/gallery", title: "Gallery", description: "Display multiple images in a rich gallery.", category: "media", keywords: ["images", "photos"], ), - EditorBlockType( + EditorBlock( name: "core/audio", title: "Audio", description: "Embed a simple audio player.", category: "media", keywords: ["music", "sound", "podcast"], ), - EditorBlockType( + EditorBlock( name: "core/video", title: "Video", description: "Embed a video from your media library or upload a new one.", category: "media", keywords: ["movie", "film"], ), - EditorBlockType( + EditorBlock( name: "core/cover", title: "Cover", description: "Add an image or video with a text overlay.", category: "media", keywords: ["banner", "hero", "cover"], ), - EditorBlockType( + EditorBlock( name: "core/file", title: "File", description: "Add a link to a downloadable file.", category: "media", keywords: ["download", "pdf", "document"], ), - EditorBlockType( + EditorBlock( name: "core/media-text", title: "Media & Text", description: "Set media and words side-by-side for a richer layout.", @@ -120,42 +120,42 @@ enum PreviewData { ), // Design blocks - EditorBlockType( + EditorBlock( name: "core/columns", title: "Columns", description: "Display content in multiple columns.", category: "design", keywords: ["layout", "columns"], ), - EditorBlockType( + EditorBlock( name: "core/group", title: "Group", description: "Gather blocks in a container.", category: "design", keywords: ["container", "wrapper", "group"], ), - EditorBlockType( + EditorBlock( name: "core/separator", title: "Separator", description: "Create a break between ideas or sections.", category: "design", keywords: ["divider", "hr"], ), - EditorBlockType( + EditorBlock( name: "core/spacer", title: "Spacer", description: "Add white space between blocks.", category: "design", keywords: ["space", "gap"], ), - EditorBlockType( + EditorBlock( name: "core/buttons", title: "Buttons", description: "Prompt visitors to take action with a group of button-style links.", category: "design", keywords: ["button", "link", "cta"], ), - EditorBlockType( + EditorBlock( name: "core/more", title: "More", description: "Content before this block will be shown in the excerpt on your archives page.", @@ -164,21 +164,21 @@ enum PreviewData { ), // Widget blocks - EditorBlockType( + EditorBlock( name: "core/search", title: "Search", description: "Help visitors find your content.", category: "widgets", keywords: ["find", "search"], ), - EditorBlockType( + EditorBlock( name: "core/archives", title: "Archives", description: "Display a date archive of your posts.", category: "widgets", keywords: ["archive", "history"], ), - EditorBlockType( + EditorBlock( name: "core/categories", title: "Categories", description: "Display a list of all categories.", @@ -187,14 +187,14 @@ enum PreviewData { ), // Theme blocks - EditorBlockType( + EditorBlock( name: "core/site-title", title: "Site Title", description: "Display your site's title.", category: "theme", keywords: ["title", "site"], ), - EditorBlockType( + EditorBlock( name: "core/site-logo", title: "Site Logo", description: "Display your site's logo.", @@ -203,28 +203,28 @@ enum PreviewData { ), // Embed blocks - EditorBlockType( + EditorBlock( name: "core-embed/youtube", title: "YouTube", description: "Embed a YouTube video.", category: "embed", keywords: ["video", "youtube"], ), - EditorBlockType( + EditorBlock( name: "core-embed/twitter", title: "Twitter", description: "Embed a tweet.", category: "embed", keywords: ["tweet", "twitter"], ), - EditorBlockType( + EditorBlock( name: "core-embed/vimeo", title: "Vimeo", description: "Embed a Vimeo video.", category: "embed", keywords: ["video", "vimeo"], ), - EditorBlockType( + EditorBlock( name: "core-embed/instagram", title: "Instagram", description: "Embed an Instagram post.", @@ -233,63 +233,63 @@ enum PreviewData { ), // Jetpack blocks - EditorBlockType( + EditorBlock( name: "jetpack/ai-assistant", title: "AI Assistant", description: "Generate text, edit content, and get suggestions using AI.", category: "text", keywords: ["ai", "artificial intelligence", "generate", "write"], ), - EditorBlockType( + EditorBlock( name: "jetpack/contact-form", title: "Contact Form", description: "Add a customizable contact form.", category: "widgets", keywords: ["form", "contact", "email"], ), - EditorBlockType( + EditorBlock( name: "jetpack/markdown", title: "Markdown", description: "Write posts or pages in plain-text Markdown syntax.", category: "text", keywords: ["markdown", "md", "formatting"], ), - EditorBlockType( + EditorBlock( name: "jetpack/tiled-gallery", title: "Tiled Gallery", description: "Display multiple images in an elegantly organized tiled layout.", category: "media", keywords: ["gallery", "images", "photos", "tiled"], ), - EditorBlockType( + EditorBlock( name: "jetpack/slideshow", title: "Slideshow", description: "Display multiple images in a slideshow.", category: "media", keywords: ["slideshow", "carousel", "gallery"], ), - EditorBlockType( + EditorBlock( name: "jetpack/map", title: "Map", description: "Add an interactive map showing one or more locations.", category: "widgets", keywords: ["map", "location", "address"], ), - EditorBlockType( + EditorBlock( name: "jetpack/business-hours", title: "Business Hours", description: "Display your business opening hours.", category: "widgets", keywords: ["hours", "schedule", "business"], ), - EditorBlockType( + EditorBlock( name: "jetpack/subscriptions", title: "Subscriptions", description: "Let visitors subscribe to your blog posts.", category: "widgets", keywords: ["subscribe", "email", "newsletter"], ), - EditorBlockType( + EditorBlock( name: "jetpack/related-posts", title: "Related Posts", description: "Display a list of related posts.", @@ -298,21 +298,21 @@ enum PreviewData { ), // Additional common blocks - EditorBlockType( + EditorBlock( name: "core/html", title: "Custom HTML", description: "Add custom HTML code and preview it as you edit.", category: "widgets", keywords: ["html", "code", "custom"], ), - EditorBlockType( + EditorBlock( name: "core/shortcode", title: "Shortcode", description: "Insert additional custom elements with WordPress shortcodes.", category: "widgets", keywords: ["shortcode", "custom"], ), - EditorBlockType( + EditorBlock( name: "core/social-links", title: "Social Icons", description: "Display icons linking to your social media profiles.", @@ -321,4 +321,4 @@ enum PreviewData { ) ] } -#endif \ No newline at end of file +#endif diff --git a/ios/Sources/GutenbergKit/Sources/BlockInserter/SearchEngine+EditorBlockType.swift b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/SearchEngine+EditorBlock.swift similarity index 87% rename from ios/Sources/GutenbergKit/Sources/BlockInserter/SearchEngine+EditorBlockType.swift rename to ios/Sources/GutenbergKit/Sources/Views/BlockInserter/SearchEngine+EditorBlock.swift index 9e2537df..ed9f64dc 100644 --- a/ios/Sources/GutenbergKit/Sources/BlockInserter/SearchEngine+EditorBlockType.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/BlockInserter/SearchEngine+EditorBlock.swift @@ -1,8 +1,6 @@ import Foundation -// MARK: - EditorBlockType Searchable Conformance - -extension EditorBlockType: Searchable { +extension EditorBlock: Searchable { func searchableFields() -> [SearchableField] { var fields: [SearchableField] = [] @@ -56,9 +54,7 @@ extension EditorBlockType: Searchable { } } -// MARK: - Convenience - -extension SearchEngine where Item == EditorBlockType { +extension SearchEngine where Item == EditorBlock { /// Default search engine for editor blocks - static let blocks = SearchEngine() -} \ No newline at end of file + static let blocks = SearchEngine() +} diff --git a/ios/Sources/GutenbergKit/Sources/Views/CameraView.swift b/ios/Sources/GutenbergKit/Sources/Views/CameraView.swift new file mode 100644 index 00000000..bd70a5be --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/CameraView.swift @@ -0,0 +1,51 @@ +import UIKit +import SwiftUI +import UniformTypeIdentifiers + +enum CameraMedia { + case photo(UIImage) + case video(URL) +} + +struct CameraView: UIViewControllerRepresentable { + var onMediaCaptured: ((CameraMedia) -> Void)? + + @Environment(\.dismiss) private var dismiss + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = .camera + picker.mediaTypes = [UTType.image.identifier, UTType.movie.identifier] // Support both photo and video + picker.videoQuality = .typeHigh + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + let parent: CameraView + + init(_ parent: CameraView) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + parent.dismiss() + + if let image = info[.originalImage] as? UIImage { + parent.onMediaCaptured?(.photo(image)) + } else if let videoURL = info[.mediaURL] as? URL { + parent.onMediaCaptured?(.video(videoURL)) + } + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.dismiss() + } + } +} diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index afae5d0e..ac7910eb 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -8,6 +8,10 @@ import { store as editorStore } from '@wordpress/editor'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { parse, serialize, createBlock } from '@wordpress/blocks'; +/** + * Internal dependencies + */ + window.editor = window.editor || {}; window.editor._savedInsertionPoint = null; @@ -16,15 +20,19 @@ export function useHostBridge( post, editorRef ) { const { undo, redo, switchEditorMode } = useDispatch( editorStore ); const { getEditedPostAttribute, getEditedPostContent } = useSelect( editorStore ); - + // Track the current selection and insertion point - const { selectedBlockClientId, blockInsertionPoint } = useSelect( ( select ) => { - const { getSelectedBlockClientId, getBlockInsertionPoint } = select( blockEditorStore ); - return { - selectedBlockClientId: getSelectedBlockClientId(), - blockInsertionPoint: getBlockInsertionPoint(), - }; - }, [] ); + const { selectedBlockClientId, blockInsertionPoint } = useSelect( + ( selectFn ) => { + const { getSelectedBlockClientId, getBlockInsertionPoint } = + selectFn( blockEditorStore ); + return { + selectedBlockClientId: getSelectedBlockClientId(), + blockInsertionPoint: getBlockInsertionPoint(), + }; + }, + [] + ); const editContent = useCallback( ( edits ) => { @@ -47,16 +55,19 @@ export function useHostBridge( post, editorRef ) { window.editor._savedInsertionPoint = { rootClientId: blockInsertionPoint?.rootClientId, index: blockInsertionPoint?.index || 0, - selectedBlockClientId: selectedBlockClientId + selectedBlockClientId, }; - } else if ( !window.editor._savedInsertionPoint || !window.editor._savedInsertionPoint.selectedBlockClientId ) { + } else if ( + ! window.editor._savedInsertionPoint || + ! window.editor._savedInsertionPoint.selectedBlockClientId + ) { // Only update to null selection if we don't have a previously selected block // This prevents overwriting a good insertion point when focus is lost if ( blockInsertionPoint && blockInsertionPoint.index !== null ) { window.editor._savedInsertionPoint = { rootClientId: blockInsertionPoint?.rootClientId, index: blockInsertionPoint?.index || 0, - selectedBlockClientId: null + selectedBlockClientId: null, }; } } @@ -120,21 +131,35 @@ export function useHostBridge( post, editorRef ) { // Check if we have a saved insertion point if ( window.editor._savedInsertionPoint ) { - const { selectedBlockClientId, index, rootClientId } = window.editor._savedInsertionPoint; - + const { + selectedBlockClientId: savedSelectedBlockClientId, + index, + rootClientId + } = window.editor._savedInsertionPoint; + // Try to use insertBlocks (plural) which might handle positioning better const { insertBlocks } = dispatch( blockEditorStore ); - - if ( selectedBlockClientId ) { + + if ( savedSelectedBlockClientId ) { // We have a selected block, insert after it // First, try to get the block directly - const selectedBlock = select( blockEditorStore ).getBlock( selectedBlockClientId ); + const selectedBlock = select( + blockEditorStore + ).getBlock( savedSelectedBlockClientId ); if ( selectedBlock ) { - const parentClientId = select( blockEditorStore ).getBlockRootClientId( selectedBlockClientId ); - const blockIndex = select( blockEditorStore ).getBlockIndex( selectedBlockClientId ); - + const parentClientId = select( + blockEditorStore + ).getBlockRootClientId( savedSelectedBlockClientId ); + const blockIndex = select( + blockEditorStore + ).getBlockIndex( savedSelectedBlockClientId ); + // Use insertBlocks with explicit position - insertBlocks( [ block ], blockIndex + 1, parentClientId ); + insertBlocks( + [ block ], + blockIndex + 1, + parentClientId + ); } else { insertBlocks( [ block ], index, rootClientId ); } @@ -146,17 +171,24 @@ export function useHostBridge( post, editorRef ) { // No saved insertion point dispatch( blockEditorStore ).insertBlock( block ); } - + // Select the newly inserted block to help with focusing setTimeout( () => { dispatch( blockEditorStore ).selectBlock( block.clientId ); }, 100 ); - } catch ( error ) { + // eslint-disable-next-line no-console console.error( 'Error in insertBlock:', error ); } }; + window.editor.insertMediaFromFiles = ( mediaItems ) => { + // Do not return the Promise to avoid host errors + import( '../../utils/bridge' ).then( ( { insertMediaFromFiles } ) => { + insertMediaFromFiles( mediaItems ); + } ); + }; + return () => { delete window.editor.setContent; delete window.editor.setTitle; @@ -166,6 +198,7 @@ export function useHostBridge( post, editorRef ) { delete window.editor.redo; delete window.editor.switchEditorMode; delete window.editor.insertBlock; + delete window.editor.insertMediaFromFiles; window.editor._savedInsertionPoint = null; }; }, [ diff --git a/src/utils/bridge.js b/src/utils/bridge.js index ffac4cbe..af794303 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -125,16 +125,23 @@ export function showBlockPicker() { try { if ( window.editorDelegate ) { - window.editorDelegate.showBlockPicker( JSON.stringify( { blockTypes } ) ); + window.editorDelegate.showBlockPicker( + JSON.stringify( { blockTypes } ) + ); } - if ( window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.editorDelegate ) { + if ( + window.webkit && + window.webkit.messageHandlers && + window.webkit.messageHandlers.editorDelegate + ) { window.webkit.messageHandlers.editorDelegate.postMessage( { message: 'showBlockPicker', body: { blockTypes }, } ); } } catch ( error ) { + // eslint-disable-next-line no-console console.error( 'Error sending message to native:', error ); } } @@ -320,3 +327,123 @@ export async function fetchEditorAssets() { url, } ); } + + + +/** + * Inserts multiple media files, creating a gallery if there are multiple images, + * or individual blocks for other media types. + * + * @param {Array} mediaItems Array of MediaInfo entities with { id, url, type, title, caption, alt, metadata } + * @return {Promise} + */ +export async function insertMediaFromFiles( mediaItems ) { + try { + // Import dependencies + const { createBlock } = await import( '@wordpress/blocks' ); + const { dispatch } = await import( '@wordpress/data' ); + const blockEditorStore = ( await import( '@wordpress/block-editor' ) ) + .store; + + // Map media types to block types + const getBlockType = ( mediaType ) => { + switch ( mediaType ) { + case 'image': + return 'core/image'; + case 'video': + return 'core/video'; + case 'audio': + return 'core/audio'; + case 'file': + default: + return 'core/file'; + } + }; + + // Separate images from other media types + const imageItems = mediaItems.filter( item => item.type === 'image' ); + const otherItems = mediaItems.filter( item => item.type !== 'image' ); + + const blocksToInsert = []; + + // If multiple images, create a gallery + if ( imageItems.length > 1 ) { + // Create inner image blocks for the gallery + const innerImageBlocks = imageItems.map( item => + createBlock( 'core/image', { + url: item.url, + id: item.id || undefined, + alt: item.alt || '', + caption: item.caption || '', + title: item.title || undefined, + }) + ); + + // Create gallery block with inner blocks + const galleryBlock = createBlock( + 'core/gallery', + { + columns: Math.min( imageItems.length, 3 ), // Max 3 columns + imageCrop: true, + linkTo: 'none', + }, + innerImageBlocks // Inner blocks parameter + ); + + blocksToInsert.push( galleryBlock ); + } else if ( imageItems.length === 1 ) { + // Single image, create an image block + const item = imageItems[ 0 ]; + const imageBlock = createBlock( 'core/image', { + url: item.url, + id: item.id || undefined, + alt: item.alt || '', + caption: item.caption || '', + title: item.title || undefined, + } ); + blocksToInsert.push( imageBlock ); + } + + // Handle non-image media types individually + for ( const item of otherItems ) { + const blockType = getBlockType( item.type ); + const blockAttributes = { + url: item.url, + id: item.id || undefined, + caption: item.caption || '', + }; + + // Add title for file blocks + if ( blockType === 'core/file' && item.title ) { + blockAttributes.fileName = item.title; + } + + // Add controls for video/audio + if ( blockType === 'core/video' || blockType === 'core/audio' ) { + blockAttributes.controls = true; + } + + const block = createBlock( blockType, blockAttributes ); + blocksToInsert.push( block ); + } + + // Insert all blocks + if ( blocksToInsert.length > 0 ) { + const insertedBlocks = dispatch( blockEditorStore ).insertBlocks( blocksToInsert ); + + if ( !insertedBlocks || insertedBlocks.length === 0 ) { + throw new Error( 'Failed to insert blocks' ); + } + } + + // TODO: this doesn't actually trigger the uploads + + } catch ( error ) { + // eslint-disable-next-line no-console + console.error( 'Failed to insert media:', error ); + logException( error, { + context: { mediaItems }, + tags: { feature: 'media-insert-multiple' }, + } ); + } +} \ No newline at end of file From 9ea27fca85baf32d9e01ce0074bac3d44c13d35d Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 4 Aug 2025 08:27:06 -0400 Subject: [PATCH 6/6] Update scheme --- .../xcshareddata/xcschemes/Gutenberg.xcscheme | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme b/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme index 6ae6d110..1a352dfa 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme @@ -54,7 +54,7 @@ + isEnabled = "NO">