diff --git a/blocksuite/affine/shared/src/__tests__/utils/file.native-image-picker.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/utils/file.native-image-picker.unit.spec.ts new file mode 100644 index 0000000000..0c8b44a168 --- /dev/null +++ b/blocksuite/affine/shared/src/__tests__/utils/file.native-image-picker.unit.spec.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + getImageFilesFromLocal, + registerNativeImageFilesPicker, +} from '../../utils'; + +describe('getImageFilesFromLocal', () => { + let originalShowPicker: ((this: HTMLInputElement) => void) | undefined; + + beforeEach(() => { + originalShowPicker = ( + HTMLInputElement.prototype as HTMLInputElement & { + showPicker?: (this: HTMLInputElement) => void; + } + ).showPicker; + + ( + HTMLInputElement.prototype as HTMLInputElement & { + showPicker?: (this: HTMLInputElement) => void; + } + ).showPicker = vi.fn(function (this: HTMLInputElement) { + this.dispatchEvent(new Event('cancel')); + }); + }); + + afterEach(() => { + if (originalShowPicker) { + ( + HTMLInputElement.prototype as HTMLInputElement & { + showPicker?: (this: HTMLInputElement) => void; + } + ).showPicker = originalShowPicker; + } else { + delete ( + HTMLInputElement.prototype as unknown as { + showPicker?: unknown; + } + ).showPicker; + } + + registerNativeImageFilesPicker(null); + vi.restoreAllMocks(); + }); + + it('prefers the registered native image picker when available', async () => { + const file = new File(['picked-image'], 'picked.png', { + type: 'image/png', + lastModified: 123, + }); + const nativePicker = vi.fn(async () => [file]); + registerNativeImageFilesPicker(nativePicker); + + const files = await getImageFilesFromLocal(); + + expect(nativePicker).toHaveBeenCalledTimes(1); + expect(files).toEqual([file]); + }); + + it('falls back to the standard picker when the native picker rejects', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const nativePicker = vi.fn(async () => { + throw new Error('Native picker failed'); + }); + registerNativeImageFilesPicker(nativePicker); + + const files = await getImageFilesFromLocal(); + + expect(nativePicker).toHaveBeenCalledTimes(1); + expect(files).toEqual([]); + expect( + ( + HTMLInputElement.prototype as HTMLInputElement & { + showPicker?: (this: HTMLInputElement) => void; + } + ).showPicker + ).toHaveBeenCalledTimes(1); + }); +}); diff --git a/blocksuite/affine/shared/src/utils/file/filesys.ts b/blocksuite/affine/shared/src/utils/file/filesys.ts index b1a025a238..47b35ce20c 100644 --- a/blocksuite/affine/shared/src/utils/file/filesys.ts +++ b/blocksuite/affine/shared/src/utils/file/filesys.ts @@ -14,8 +14,14 @@ interface OpenFilePickerOptions { multiple?: boolean | undefined; } +export type NativeImageFilesPicker = () => Promise; + +const NATIVE_IMAGE_FILES_PICKER_KEY = + '__AFFINE_NATIVE_IMAGE_FILES_PICKER__' as const; + declare global { interface Window { + [NATIVE_IMAGE_FILES_PICKER_KEY]?: NativeImageFilesPicker; // Window API: showOpenFilePicker showOpenFilePicker?: ( options?: OpenFilePickerOptions @@ -29,6 +35,17 @@ declare global { } } +export function registerNativeImageFilesPicker( + picker: NativeImageFilesPicker | null +) { + if (picker) { + window[NATIVE_IMAGE_FILES_PICKER_KEY] = picker; + return; + } + + delete window[NATIVE_IMAGE_FILES_PICKER_KEY]; +} + // Minimal polyfill for FileSystemDirectoryHandle to iterate over files interface FileSystemDirectoryHandle { kind: 'directory'; @@ -195,7 +212,18 @@ export async function snapshotFile(file: File, relativePath?: string) { } export async function snapshotFiles(files: File[]) { - return Promise.all(files.map(file => snapshotFile(file))); + const results = await Promise.allSettled( + files.map(file => snapshotFile(file)) + ); + const snapshots: File[] = []; + for (const result of results) { + if (result.status === 'fulfilled') { + snapshots.push(result.value); + } else { + console.error('Failed to snapshot file', result.reason); + } + } + return snapshots; } function canUseFileSystemAccessAPI( @@ -332,7 +360,11 @@ export async function openDirectory( if (fileHandle.getFile) { const file = await fileHandle.getFile(); if (options?.snapshot) { - files.push(await snapshotFile(file, relativePath)); + try { + files.push(await snapshotFile(file, relativePath)); + } catch (error) { + console.error('Failed to snapshot file', relativePath, error); + } } else { Object.defineProperty(file, 'webkitRelativePath', { value: relativePath, @@ -410,6 +442,15 @@ export async function openSingleFileWith( } export async function getImageFilesFromLocal() { + const nativePicker = window[NATIVE_IMAGE_FILES_PICKER_KEY]; + if (nativePicker) { + try { + return (await nativePicker()) ?? []; + } catch (err) { + console.error(err); + } + } + const files = await openFilesWith('Images'); return files ?? []; } diff --git a/blocksuite/affine/widgets/keyboard-toolbar/src/keyboard-toolbar.ts b/blocksuite/affine/widgets/keyboard-toolbar/src/keyboard-toolbar.ts index 11b85387e4..ca8d5f18dd 100644 --- a/blocksuite/affine/widgets/keyboard-toolbar/src/keyboard-toolbar.ts +++ b/blocksuite/affine/widgets/keyboard-toolbar/src/keyboard-toolbar.ts @@ -8,6 +8,7 @@ import { requiredProperties, ShadowlessElement, } from '@blocksuite/std'; +import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/std/inline'; import { effect, type Signal, signal } from '@preact/signals-core'; import { html } from 'lit'; import { property } from 'lit/decorators.js'; @@ -263,6 +264,7 @@ export class AffineKeyboardToolbar extends SignalWatcher( override connectedCallback() { super.connectedCallback(); + this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true'); // There are two cases that `_expanded$` will be true: // 1. when virtual keyboard is opened, the panel need to be expanded and overlapped by the keyboard, diff --git a/blocksuite/framework/std/src/__tests__/inline/active.unit.spec.ts b/blocksuite/framework/std/src/__tests__/inline/active.unit.spec.ts new file mode 100644 index 0000000000..b682fa12a1 --- /dev/null +++ b/blocksuite/framework/std/src/__tests__/inline/active.unit.spec.ts @@ -0,0 +1,66 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, describe, expect, test } from 'vitest'; + +import { shouldDeactivateEditorOnFocusOut } from '../../inline/range/active.js'; +import { RANGE_SYNC_EXCLUDE_ATTR } from '../../inline/range/consts.js'; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe('editor active helpers', () => { + test('keeps editor active when focus moves into an excluded widget', () => { + const host = document.createElement('editor-host'); + const paragraph = document.createElement('div'); + host.append(paragraph); + + const widgetRoot = document.createElement('div'); + widgetRoot.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true'); + const widgetButton = document.createElement('button'); + widgetRoot.append(widgetButton); + + document.body.append(host, widgetRoot); + + expect(shouldDeactivateEditorOnFocusOut(host, widgetButton)).toBe(false); + }); + + test('keeps editor active when focus stays inside the editor host', () => { + const host = document.createElement('editor-host'); + const input = document.createElement('input'); + host.append(input); + document.body.append(host); + + expect(shouldDeactivateEditorOnFocusOut(host, input)).toBe(false); + }); + + test('deactivates editor when focus moves to a regular external control', () => { + const host = document.createElement('editor-host'); + const paragraph = document.createElement('div'); + host.append(paragraph); + + const externalButton = document.createElement('button'); + document.body.append(host, externalButton); + + expect(shouldDeactivateEditorOnFocusOut(host, externalButton)).toBe(true); + }); + + test('keeps editor active on focusout when related target is null', () => { + // Focus leaving to nowhere (e.g. block-level selection) must not deactivate + // the editor via `focusout`; the host `blur` handler owns that case. + const host = document.createElement('editor-host'); + document.body.append(host); + + expect(shouldDeactivateEditorOnFocusOut(host, null)).toBe(false); + }); + + test('keeps editor active when the related target is not a DOM node', () => { + const host = document.createElement('editor-host'); + document.body.append(host); + + expect(shouldDeactivateEditorOnFocusOut(host, new EventTarget())).toBe( + false + ); + }); +}); diff --git a/blocksuite/framework/std/src/event/dispatcher.ts b/blocksuite/framework/std/src/event/dispatcher.ts index bc08bc8803..97be1e3ef9 100644 --- a/blocksuite/framework/std/src/event/dispatcher.ts +++ b/blocksuite/framework/std/src/event/dispatcher.ts @@ -4,6 +4,7 @@ import { signal } from '@preact/signals-core'; import { LifeCycleWatcher } from '../extension/index.js'; import { KeymapIdentifier } from '../identifier.js'; +import { shouldDeactivateEditorOnFocusOut } from '../inline/range/active.js'; import type { BlockStdScope } from '../scope/index.js'; import { type BlockComponent, EditorHost } from '../view/index.js'; import { @@ -174,16 +175,21 @@ export class UIEventDispatcher extends LifeCycleWatcher { this._setActive(true); }); this.disposables.addFromEvent(document, 'focusout', e => { - if (e.relatedTarget && !this.host.contains(e.relatedTarget as Node)) { + if (shouldDeactivateEditorOnFocusOut(this.host, e.relatedTarget)) { this._setActive(false); } }); - this.disposables.addFromEvent(this.host, 'blur', () => { + this.disposables.addFromEvent(this.host, 'blur', e => { if (_dragging) { return; } - this._setActive(false); + if ( + !e.relatedTarget || + shouldDeactivateEditorOnFocusOut(this.host, e.relatedTarget) + ) { + this._setActive(false); + } }); this.disposables.addFromEvent(this.host, 'dragover', () => { _dragging = true; diff --git a/blocksuite/framework/std/src/inline/range/active.ts b/blocksuite/framework/std/src/inline/range/active.ts index 62a40a0426..c556460bda 100644 --- a/blocksuite/framework/std/src/inline/range/active.ts +++ b/blocksuite/framework/std/src/inline/range/active.ts @@ -1,4 +1,46 @@ -import { RANGE_SYNC_EXCLUDE_ATTR } from './consts'; +import { RANGE_SYNC_EXCLUDE_ATTR } from './consts.js'; + +function getClosestElement(target: EventTarget | null) { + if (target instanceof Element) { + return target; + } + + if (target instanceof Node) { + return target.parentElement; + } + + return null; +} + +export function isRangeSyncExcludedTarget(target: EventTarget | null) { + return !!getClosestElement(target)?.closest( + `[${RANGE_SYNC_EXCLUDE_ATTR}="true"]` + ); +} + +export function shouldDeactivateEditorOnFocusOut( + editorHost: HTMLElement, + relatedTarget: EventTarget | null +) { + // A missing or non-DOM `relatedTarget` means focus is not moving to a + // trackable element (e.g. block-level selection or a keyboard action). The + // document `focusout` handler must keep the editor active in this case so the + // current block selection is preserved; leaving-to-nowhere is handled + // explicitly by the host `blur` handler instead. + if (!relatedTarget || !(relatedTarget instanceof Node)) { + return false; + } + + if (editorHost.contains(relatedTarget)) { + return false; + } + + if (isRangeSyncExcludedTarget(relatedTarget)) { + return false; + } + + return true; +} /** * Check if the active element is in the editor host. @@ -11,8 +53,7 @@ export function isActiveInEditor(editorHost: HTMLElement) { const currentActiveElement = document.activeElement; if (!currentActiveElement) return false; // The input or textarea in the widget should be ignored. - if (currentActiveElement.closest(`[${RANGE_SYNC_EXCLUDE_ATTR}="true"]`)) - return false; + if (isRangeSyncExcludedTarget(currentActiveElement)) return false; const currentEditorHost = currentActiveElement?.closest('editor-host'); if (!currentEditorHost) return false; return currentEditorHost === editorHost; diff --git a/packages/frontend/apps/electron/test/main/byok-storage.spec.ts b/packages/frontend/apps/electron/test/main/byok-storage.spec.ts index 8bbcd3e2ab..f5b3ac4739 100644 --- a/packages/frontend/apps/electron/test/main/byok-storage.spec.ts +++ b/packages/frontend/apps/electron/test/main/byok-storage.spec.ts @@ -2,7 +2,15 @@ import os from 'node:os'; import path from 'node:path'; import fs from 'fs-extra'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from 'vitest'; const electronMock = vi.hoisted(() => ({ tmpDir: '', @@ -32,6 +40,13 @@ vi.mock('../../src/main/logger', () => ({ }, })); +// Warm the handler module's transform once so the first per-test dynamic +// import doesn't race the default 60s test timeout on loaded CI shards, where +// cold-transforming the heavy `@toeverything/infra` graph can starve. +beforeAll(async () => { + await import('@affine/electron/main/byok-storage/handlers'); +}, 120_000); + beforeEach(async () => { vi.useRealTimers(); vi.resetModules(); diff --git a/packages/frontend/apps/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved b/packages/frontend/apps/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved index 721dadcdad..fc74b22a05 100644 --- a/packages/frontend/apps/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/packages/frontend/apps/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Lakr233/ListViewKit", "state" : { - "revision" : "07f7adfa0629f8647991e3c148b7d3e060fe2917", - "version" : "1.2.0" + "revision" : "a4372d7f90c846d834c1f1575d1af0050d70fa0f", + "version" : "1.1.6" } }, { @@ -63,15 +63,6 @@ "version" : "3.9.1" } }, - { - "identity" : "msdisplaylink", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Lakr233/MSDisplayLink", - "state" : { - "revision" : "ebf5823cb5fc1326639d9a05bc06d16bbe82989f", - "version" : "2.0.8" - } - }, { "identity" : "purchases-ios-spm", "kind" : "remoteSourceControl", diff --git a/packages/frontend/apps/ios/App/App/AffineViewController.swift b/packages/frontend/apps/ios/App/App/AffineViewController.swift index 7ee5d3f33e..afeb882e90 100644 --- a/packages/frontend/apps/ios/App/App/AffineViewController.swift +++ b/packages/frontend/apps/ios/App/App/AffineViewController.swift @@ -3,8 +3,13 @@ import Intelligents import UIKit import WebKit -class AFFiNEViewController: CAPBridgeViewController, UIScrollViewDelegate { +class AFFiNEViewController: CAPBridgeViewController, UIScrollViewDelegate, AffineThemeConfigurable { var intelligentsButton: IntelligentsButton? + var appThemeUserInterfaceStyle: UIUserInterfaceStyle = .unspecified { + didSet { + overrideUserInterfaceStyle = appThemeUserInterfaceStyle + } + } override func viewDidLoad() { super.viewDidLoad() @@ -52,9 +57,11 @@ class AFFiNEViewController: CAPBridgeViewController, UIScrollViewDelegate { override func capacitorDidLoad() { let plugins: [CAPPlugin] = [ + AffineThemePlugin(associatedController: self), AuthPlugin(), CookiePlugin(), HashcashPlugin(), + ImagePickerPlugin(associatedController: self), NavigationGesturePlugin(), NbStorePlugin(), PayWallPlugin(associatedController: self), @@ -110,12 +117,6 @@ class AFFiNEViewController: CAPBridgeViewController, UIScrollViewDelegate { scrollView.zoomScale = 1.0 } - func scrollViewDidScroll(_ scrollView: UIScrollView) { - if scrollView.contentOffset != .zero { - scrollView.contentOffset = .zero - } - } - // MARK: - Web Content Process Crash Recovery // NOTE: Capacitor's CAPBridgeViewController owns the WKWebView diff --git a/packages/frontend/apps/ios/App/App/ApplicationBridgedWindowScript.swift b/packages/frontend/apps/ios/App/App/ApplicationBridgedWindowScript.swift index 4f16daa364..5140e50e0c 100644 --- a/packages/frontend/apps/ios/App/App/ApplicationBridgedWindowScript.swift +++ b/packages/frontend/apps/ios/App/App/ApplicationBridgedWindowScript.swift @@ -18,6 +18,7 @@ enum ApplicationBridgedWindowScript: String { case getCurrentWorkspaceId = "window.getCurrentWorkspaceId();" case getCurrentDocId = "window.getCurrentDocId();" case getCurrentI18nLocale = "window.getCurrentI18nLocale();" + case getCurrentThemeMode = "window.getCurrentThemeMode();" case createNewDocByMarkdownInCurrentWorkspace = "return await window.createNewDocByMarkdownInCurrentWorkspace(markdown, title);" var requiresAsyncContext: Bool { diff --git a/packages/frontend/apps/ios/App/App/Plugins/AffineTheme/AffineThemePlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/AffineTheme/AffineThemePlugin.swift new file mode 100644 index 0000000000..39c7d9bf57 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Plugins/AffineTheme/AffineThemePlugin.swift @@ -0,0 +1,47 @@ +import Capacitor +import UIKit + +protocol AffineThemeConfigurable: AnyObject { + var appThemeUserInterfaceStyle: UIUserInterfaceStyle { get set } +} + +private enum AffineThemeMode: String { + case dark + case light + case system + + var userInterfaceStyle: UIUserInterfaceStyle { + switch self { + case .dark: + .dark + case .light: + .light + case .system: + .unspecified + } + } +} + +@objc(AffineThemePlugin) +public final class AffineThemePlugin: CAPPlugin, CAPBridgedPlugin { + init(associatedController: UIViewController?) { + controller = associatedController + super.init() + } + + weak var controller: UIViewController? + + public let identifier = "AffineThemePlugin" + public let jsName = "AffineTheme" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "onThemeChanged", returnType: CAPPluginReturnPromise), + ] + + @objc func onThemeChanged(_ call: CAPPluginCall) { + DispatchQueue.main.async { + let themeMode = AffineThemeMode(rawValue: call.getString("themeMode") ?? "") ?? .system + (self.controller as? AffineThemeConfigurable)?.appThemeUserInterfaceStyle = themeMode.userInterfaceStyle + call.resolve() + } + } +} diff --git a/packages/frontend/apps/ios/App/App/Plugins/ImagePicker/ImagePickerPlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/ImagePicker/ImagePickerPlugin.swift new file mode 100644 index 0000000000..230450a4c2 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Plugins/ImagePicker/ImagePickerPlugin.swift @@ -0,0 +1,689 @@ +import AffineResources +import Capacitor +import Foundation +import PhotosUI +import UIKit +import UniformTypeIdentifiers + +private struct PickedImageFile { + let path: String + let name: String + let mimeType: String + let lastModified: Int64 + + var dictionary: [String: Any] { + [ + "path": path, + "name": name, + "mimeType": mimeType, + "lastModified": lastModified, + ] + } +} + +@objc(ImagePickerPlugin) +public class ImagePickerPlugin: CAPPlugin, CAPBridgedPlugin { + init( + associatedController: UIViewController? + ) { + controller = associatedController + super.init() + } + + weak var controller: UIViewController? + + public let identifier = "ImagePickerPlugin" + public let jsName = "ImagePicker" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "pickImages", returnType: CAPPluginReturnPromise), + ] + + private var pendingCall: CAPPluginCall? + private var allowsMultipleSelection = true + + @objc func pickImages(_ call: CAPPluginCall) { + DispatchQueue.main.async { + guard self.pendingCall == nil else { + call.reject("Another image picker request is already in progress.") + return + } + + do { + try self.clearStagingDirectory() + let presenter = try self.resolvePresenter() + self.pendingCall = call + self.allowsMultipleSelection = call.getBool("multiple") ?? true + self.presentSourceActionSheet(from: presenter) + } catch { + call.reject("Failed to present image picker.", nil, error) + } + } + } + + private func resolvePresenter() throws -> UIViewController { + if let controller { + return controller + } + if let bridge, let viewController = bridge.viewController { + return viewController + } + throw NSError( + domain: "ImagePickerPlugin", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No view controller available."] + ) + } + + private func presentSourceActionSheet(from presenter: UIViewController) { + let presentSheet = { [weak self, weak presenter] (theme: ImagePickerSheetTheme) in + guard let self, let presenter else { return } + + let sheetController = ImagePickerSourceSheetViewController( + actions: [ + ImagePickerSheetAction( + title: "Photo Library", + iconSystemName: "photo.on.rectangle.angled" + ) { [weak self, weak presenter] in + guard let self, let presenter else { return } + self.presentPhotoLibrary(from: presenter) + }, + ImagePickerSheetAction( + title: "Take Photo", + iconSystemName: "camera", + isEnabled: UIImagePickerController.isSourceTypeAvailable(.camera) + ) { [weak self, weak presenter] in + guard let self, let presenter else { return } + self.presentCamera(from: presenter) + }, + ImagePickerSheetAction( + title: "Choose Files", + iconSystemName: "folder" + ) { [weak self, weak presenter] in + guard let self, let presenter else { return } + self.presentDocumentPicker(from: presenter) + }, + ], + theme: theme, + onCancel: { [weak self] in + self?.resolvePendingCall(files: [], canceled: true) + } + ) + + sheetController.overrideUserInterfaceStyle = theme.userInterfaceStyle + sheetController.modalPresentationStyle = .overFullScreen + sheetController.modalTransitionStyle = .crossDissolve + presenter.present(sheetController, animated: false) + } + + if let webView = bridge?.webView { + webView.evaluateScript(.getCurrentThemeMode) { output in + DispatchQueue.main.async { + presentSheet(Self.sheetTheme(for: output as? String)) + } + } + return + } + + presentSheet(.light) + } + + private static func sheetTheme(for themeMode: String?) -> ImagePickerSheetTheme { + switch themeMode { + case "dark": + return .dark + case "system": + return UITraitCollection.current.userInterfaceStyle == .dark ? .dark : .light + default: + return .light + } + } + + private func presentPhotoLibrary(from presenter: UIViewController) { + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + configuration.filter = .images + configuration.selectionLimit = allowsMultipleSelection ? 0 : 1 + + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + presenter.present(picker, animated: true) + } + + private func presentCamera(from presenter: UIViewController) { + let picker = UIImagePickerController() + picker.sourceType = .camera + picker.mediaTypes = [UTType.image.identifier] + picker.allowsEditing = false + picker.delegate = self + presenter.present(picker, animated: true) + } + + private func presentDocumentPicker(from presenter: UIViewController) { + let picker = UIDocumentPickerViewController( + forOpeningContentTypes: [.image], + asCopy: true + ) + picker.delegate = self + picker.allowsMultipleSelection = allowsMultipleSelection + presenter.present(picker, animated: true) + } + + private func resolvePendingCall(files: [PickedImageFile], canceled: Bool) { + pendingCall?.resolve([ + "files": files.map(\.dictionary), + "canceled": canceled, + ]) + pendingCall = nil + } + + private func rejectPendingCall(message: String, error: Error? = nil) { + pendingCall?.reject(message, nil, error) + pendingCall = nil + } + + private func createStagingDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("affine-image-picker", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + private func clearStagingDirectory() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("affine-image-picker", isDirectory: true) + guard FileManager.default.fileExists(atPath: directory.path) else { return } + try FileManager.default.removeItem(at: directory) + } + + private func uniqueFileURL(in directory: URL, fileName: String) -> URL { + let strippedName = (fileName as NSString).lastPathComponent + let sanitizedName = strippedName + .components(separatedBy: CharacterSet(charactersIn: "/\\:")) + .filter { !$0.isEmpty && $0 != "." && $0 != ".." } + .joined(separator: "-") + let safeName = sanitizedName.isEmpty ? UUID().uuidString : sanitizedName + return directory.appendingPathComponent("\(UUID().uuidString)-\(safeName)") + } + + private func pickedFile(from sourceURL: URL, suggestedName: String? = nil) throws -> PickedImageFile { + let stagingDirectory = try createStagingDirectory() + let fileName = suggestedName ?? sourceURL.lastPathComponent + let destinationURL = uniqueFileURL(in: stagingDirectory, fileName: fileName) + + if FileManager.default.fileExists(atPath: destinationURL.path) { + try FileManager.default.removeItem(at: destinationURL) + } + + try FileManager.default.copyItem(at: sourceURL, to: destinationURL) + + let resourceValues = try destinationURL.resourceValues(forKeys: [.contentModificationDateKey]) + let mimeType = UTType(filenameExtension: destinationURL.pathExtension)?.preferredMIMEType ?? "image/*" + let lastModified = Int64((resourceValues.contentModificationDate ?? Date()).timeIntervalSince1970 * 1000) + + return PickedImageFile( + path: destinationURL.path, + name: destinationURL.lastPathComponent, + mimeType: mimeType, + lastModified: lastModified + ) + } + + private func pickedFile(from image: UIImage) throws -> PickedImageFile { + let stagingDirectory = try createStagingDirectory() + let destinationURL = uniqueFileURL(in: stagingDirectory, fileName: "captured-image.jpg") + + guard let data = image.jpegData(compressionQuality: 0.92) else { + throw NSError( + domain: "ImagePickerPlugin", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Failed to encode captured image."] + ) + } + + try data.write(to: destinationURL, options: .atomic) + + return PickedImageFile( + path: destinationURL.path, + name: destinationURL.lastPathComponent, + mimeType: "image/jpeg", + lastModified: Int64(Date().timeIntervalSince1970 * 1000) + ) + } +} + +private struct ImagePickerSheetAction { + let title: String + let iconSystemName: String + var isEnabled: Bool = true + let handler: () -> Void +} + +private enum ImagePickerSheetTheme { + case dark + case light + + var userInterfaceStyle: UIUserInterfaceStyle { + switch self { + case .dark: + return .dark + case .light: + return .light + } + } + + private var traitCollection: UITraitCollection { + UITraitCollection(userInterfaceStyle: userInterfaceStyle) + } + + private func resolvedColor(_ color: AffineColors) -> UIColor { + color.uiColor.resolvedColor(with: traitCollection) + } + + var dimmingColor: UIColor { + UIColor.black.withAlphaComponent(self == .dark ? 0.44 : 0.18) + } + + var backgroundColor: UIColor { + resolvedColor(.layerBackgroundPrimary) + } + + var secondaryBackgroundColor: UIColor { + resolvedColor(.layerBackgroundSecondary) + } + + var pressedBackgroundColor: UIColor { + resolvedColor(.layerBackgroundSecondary) + } + + var borderColor: UIColor { + resolvedColor(.layerBorder) + } + + var textColor: UIColor { + resolvedColor(.textPrimary) + } + + var iconColor: UIColor { + resolvedColor(.buttonPrimary) + } + + var disabledTextColor: UIColor { + resolvedColor(.textTertiary) + } +} + +private final class ImagePickerSourceSheetViewController: UIViewController { + private let actions: [ImagePickerSheetAction] + private let theme: ImagePickerSheetTheme + private let onCancel: () -> Void + private let dimmingView = UIView() + private let contentStackView = UIStackView() + private let actionsContainerView = UIView() + private let actionsStackView = UIStackView() + private let cancelButton = UIButton(type: .system) + private let handleView = UIView() + private var separatorViews: [UIView] = [] + private var hasResolvedCancellation = false + private var isDismissingSheet = false + + init(actions: [ImagePickerSheetAction], theme: ImagePickerSheetTheme, onCancel: @escaping () -> Void) { + self.actions = actions + self.theme = theme + self.onCancel = onCancel + super.init(nibName: nil, bundle: nil) + modalPresentationCapturesStatusBarAppearance = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupViews() + setupConstraints() + updateAppearance() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + animateIn() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + guard traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) else { + return + } + updateAppearance() + } + + private func setupViews() { + view.backgroundColor = .clear + + dimmingView.alpha = 0 + dimmingView.addGestureRecognizer(UITapGestureRecognizer( + target: self, + action: #selector(handleCancelTapped) + )) + view.addSubview(dimmingView) + + contentStackView.axis = .vertical + contentStackView.spacing = 12 + contentStackView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(contentStackView) + + handleView.translatesAutoresizingMaskIntoConstraints = false + handleView.layer.cornerRadius = 2.5 + actionsContainerView.addSubview(handleView) + + actionsContainerView.layer.cornerRadius = 20 + actionsContainerView.clipsToBounds = true + actionsContainerView.layer.borderWidth = 1 / UIScreen.main.scale + + actionsStackView.axis = .vertical + actionsStackView.translatesAutoresizingMaskIntoConstraints = false + actionsContainerView.addSubview(actionsStackView) + + for (index, action) in actions.enumerated() { + let button = ImagePickerActionButton(action: action, theme: theme) + button.addTarget(self, action: #selector(handleActionTap(_:)), for: .touchUpInside) + button.tag = index + actionsStackView.addArrangedSubview(button) + + if index < actions.count - 1 { + let separator = UIView() + separator.translatesAutoresizingMaskIntoConstraints = false + separatorViews.append(separator) + actionsStackView.addArrangedSubview(separator) + separator.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true + } + } + + cancelButton.configuration = .plain() + cancelButton.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) + cancelButton.setTitle("Cancel", for: .normal) + cancelButton.layer.cornerRadius = 16 + cancelButton.layer.borderWidth = 1 / UIScreen.main.scale + cancelButton.addTarget(self, action: #selector(handleCancelTapped), for: .touchUpInside) + cancelButton.heightAnchor.constraint(equalToConstant: 56).isActive = true + + contentStackView.addArrangedSubview(actionsContainerView) + contentStackView.addArrangedSubview(cancelButton) + } + + private func setupConstraints() { + dimmingView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + dimmingView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + dimmingView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + dimmingView.topAnchor.constraint(equalTo: view.topAnchor), + dimmingView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 12), + contentStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -12), + contentStackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -8), + + handleView.topAnchor.constraint(equalTo: actionsContainerView.topAnchor, constant: 8), + handleView.centerXAnchor.constraint(equalTo: actionsContainerView.centerXAnchor), + handleView.widthAnchor.constraint(equalToConstant: 36), + handleView.heightAnchor.constraint(equalToConstant: 5), + + actionsStackView.leadingAnchor.constraint(equalTo: actionsContainerView.leadingAnchor), + actionsStackView.trailingAnchor.constraint(equalTo: actionsContainerView.trailingAnchor), + actionsStackView.topAnchor.constraint(equalTo: handleView.bottomAnchor, constant: 12), + actionsStackView.bottomAnchor.constraint(equalTo: actionsContainerView.bottomAnchor, constant: -8), + ]) + } + + private func updateAppearance() { + dimmingView.backgroundColor = theme.dimmingColor + actionsContainerView.backgroundColor = theme.backgroundColor + actionsContainerView.layer.borderColor = theme.borderColor.cgColor + handleView.backgroundColor = theme.borderColor + + separatorViews.forEach { $0.backgroundColor = theme.borderColor } + + cancelButton.backgroundColor = theme.backgroundColor + cancelButton.layer.borderColor = theme.borderColor.cgColor + cancelButton.setTitleColor(theme.textColor, for: .normal) + } + + private func animateIn() { + contentStackView.transform = CGAffineTransform(translationX: 0, y: 24) + contentStackView.alpha = 0 + + UIView.animate(withDuration: 0.24, delay: 0, options: [.curveEaseOut]) { + self.dimmingView.alpha = 1 + self.contentStackView.alpha = 1 + self.contentStackView.transform = .identity + } + } + + private func dismissSheet(resolveCancellation: Bool = false, completion: (() -> Void)? = nil) { + guard !isDismissingSheet else { return } + isDismissingSheet = true + + UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseIn]) { + self.dimmingView.alpha = 0 + self.contentStackView.alpha = 0 + self.contentStackView.transform = CGAffineTransform(translationX: 0, y: 24) + } completion: { _ in + self.dismiss(animated: false) { + if resolveCancellation, !self.hasResolvedCancellation { + self.hasResolvedCancellation = true + self.onCancel() + } + completion?() + } + } + } + + @objc private func handleCancelTapped() { + dismissSheet(resolveCancellation: true) + } + + @objc private func handleActionTap(_ sender: UIButton) { + let action = actions[sender.tag] + guard action.isEnabled else { return } + + dismissSheet { + action.handler() + } + } +} + +private final class ImagePickerActionButton: UIButton { + private let iconContainerView = UIView() + private let iconView = UIImageView() + private let titleLabelView = UILabel() + private let isActionEnabled: Bool + private let theme: ImagePickerSheetTheme + + init(action: ImagePickerSheetAction, theme: ImagePickerSheetTheme) { + isActionEnabled = action.isEnabled + self.theme = theme + super.init(frame: .zero) + + translatesAutoresizingMaskIntoConstraints = false + heightAnchor.constraint(equalToConstant: 56).isActive = true + contentHorizontalAlignment = .fill + contentVerticalAlignment = .fill + + iconContainerView.translatesAutoresizingMaskIntoConstraints = false + iconContainerView.layer.cornerRadius = 16 + addSubview(iconContainerView) + + iconView.translatesAutoresizingMaskIntoConstraints = false + iconView.contentMode = .scaleAspectFit + iconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold) + iconView.image = UIImage(systemName: action.iconSystemName) + iconContainerView.addSubview(iconView) + + titleLabelView.translatesAutoresizingMaskIntoConstraints = false + titleLabelView.font = .systemFont(ofSize: 17, weight: .medium) + titleLabelView.text = action.title + addSubview(titleLabelView) + + NSLayoutConstraint.activate([ + iconContainerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + iconContainerView.centerYAnchor.constraint(equalTo: centerYAnchor), + iconContainerView.widthAnchor.constraint(equalToConstant: 32), + iconContainerView.heightAnchor.constraint(equalToConstant: 32), + + iconView.centerXAnchor.constraint(equalTo: iconContainerView.centerXAnchor), + iconView.centerYAnchor.constraint(equalTo: iconContainerView.centerYAnchor), + + titleLabelView.leadingAnchor.constraint(equalTo: iconContainerView.trailingAnchor, constant: 12), + titleLabelView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + titleLabelView.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + + isEnabled = action.isEnabled + updateAppearance() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var isHighlighted: Bool { + didSet { + guard isEnabled else { return } + backgroundColor = isHighlighted ? theme.pressedBackgroundColor : .clear + } + } + + private func updateAppearance() { + backgroundColor = .clear + iconContainerView.backgroundColor = theme.secondaryBackgroundColor + + if isActionEnabled { + iconView.tintColor = theme.iconColor + titleLabelView.textColor = theme.textColor + } else { + iconView.tintColor = theme.disabledTextColor + titleLabelView.textColor = theme.disabledTextColor + } + } +} + +extension ImagePickerPlugin: PHPickerViewControllerDelegate { + public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + + guard !results.isEmpty else { + resolvePendingCall(files: [], canceled: true) + return + } + + let group = DispatchGroup() + let lock = NSLock() + var pickedFiles = Array(repeating: nil, count: results.count) + var pickedError: Error? + + for (index, result) in results.enumerated() { + let itemProvider = result.itemProvider + guard itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) else { + continue + } + + group.enter() + itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { [weak self] url, error in + defer { group.leave() } + guard let self else { return } + + if let error { + lock.lock() + pickedError = pickedError ?? error + lock.unlock() + return + } + + guard let url else { + lock.lock() + pickedError = pickedError ?? NSError( + domain: "ImagePickerPlugin", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "No image URL returned from photo library."] + ) + lock.unlock() + return + } + + do { + let file = try self.pickedFile(from: url, suggestedName: itemProvider.suggestedName) + lock.lock() + pickedFiles[index] = file + lock.unlock() + } catch { + lock.lock() + pickedError = pickedError ?? error + lock.unlock() + } + } + } + + group.notify(queue: .main) { + if let pickedError { + self.rejectPendingCall(message: "Failed to load selected images.", error: pickedError) + return + } + + self.resolvePendingCall( + files: pickedFiles.compactMap { $0 }, + canceled: false + ) + } + } +} + +extension ImagePickerPlugin: UIImagePickerControllerDelegate, UINavigationControllerDelegate { + public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) + resolvePendingCall(files: [], canceled: true) + } + + public func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + picker.dismiss(animated: true) + + do { + guard let image = info[.originalImage] as? UIImage else { + throw NSError( + domain: "ImagePickerPlugin", + code: 4, + userInfo: [NSLocalizedDescriptionKey: "No captured image returned from camera."] + ) + } + let file = try pickedFile(from: image) + resolvePendingCall(files: [file], canceled: false) + } catch { + rejectPendingCall(message: "Failed to process captured image.", error: error) + } + } +} + +extension ImagePickerPlugin: UIDocumentPickerDelegate { + public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + do { + let files = try urls.compactMap { url -> PickedImageFile? in + guard url.startAccessingSecurityScopedResource() else { + return nil + } + defer { url.stopAccessingSecurityScopedResource() } + return try pickedFile(from: url) + } + resolvePendingCall(files: files, canceled: false) + } catch { + rejectPendingCall(message: "Failed to import selected files.", error: error) + } + } + + public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + resolvePendingCall(files: [], canceled: true) + } +} diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift index 856ffa6c3c..9cbf51d8bc 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.swift @@ -21,7 +21,7 @@ let package = Package( .package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.1"), .package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.2.0"), .package(url: "https://github.com/Recouse/EventSource.git", from: "0.1.8"), - .package(url: "https://github.com/Lakr233/ListViewKit.git", from: "1.2.0"), + .package(url: "https://github.com/Lakr233/ListViewKit.git", .upToNextMinor(from: "1.1.6")), .package(url: "https://github.com/Lakr233/MarkdownView.git", from: "3.9.1"), ], targets: [ diff --git a/packages/frontend/apps/ios/src/app.tsx b/packages/frontend/apps/ios/src/app.tsx index bd90e3de21..62097c591e 100644 --- a/packages/frontend/apps/ios/src/app.tsx +++ b/packages/frontend/apps/ios/src/app.tsx @@ -54,9 +54,11 @@ import { MarkdownAdapter, titleMiddleware, } from '@blocksuite/affine/shared/adapters'; +import { registerNativeImageFilesPicker } from '@blocksuite/affine/shared/utils'; import { MarkdownTransformer } from '@blocksuite/affine/widgets/linked-doc'; import { App as CapacitorApp } from '@capacitor/app'; import { Browser } from '@capacitor/browser'; +import { Capacitor } from '@capacitor/core'; import { Haptics } from '@capacitor/haptics'; import { Keyboard, KeyboardStyle } from '@capacitor/keyboard'; import { Framework, FrameworkRoot, getCurrentStore } from '@toeverything/infra'; @@ -69,8 +71,10 @@ import { RouterProvider } from 'react-router-dom'; import { BlocksuiteMenuConfigProvider } from './bs-menu-config'; import { ModalConfigProvider } from './modal-config'; +import { AffineTheme } from './plugins/affine-theme'; import { Auth } from './plugins/auth'; import { Hashcash } from './plugins/hashcash'; +import { ImagePicker } from './plugins/image-picker'; import { NbStoreNativeDBApis } from './plugins/nbstore'; import { PayWall } from './plugins/paywall'; import { Preview } from './plugins/preview'; @@ -237,6 +241,39 @@ registerNativePreviewHandlers({ renderMermaidSvg: request => Preview.renderMermaidSvg(request), renderTypstSvg: request => Preview.renderTypstSvg(request), }); +registerNativeImageFilesPicker(async () => { + const result = await ImagePicker.pickImages({ multiple: true }); + if (result.canceled || result.files.length === 0) { + return []; + } + + const settled = await Promise.allSettled( + result.files.map(async file => { + const filePath = file.path.startsWith('file://') + ? file.path + : `file://${file.path}`; + const response = await fetch(Capacitor.convertFileSrc(filePath)); + if (!response.ok) { + throw new Error( + `Failed to read image picker file: ${file.name} (status ${response.status})` + ); + } + + const blob = await response.blob(); + return new File([blob], file.name, { + type: file.mimeType || blob.type || 'image/*', + lastModified: file.lastModified, + }); + }) + ); + + return settled + .filter( + (settledResult): settledResult is PromiseFulfilledResult => + settledResult.status === 'fulfilled' + ) + .map(settledResult => settledResult.value); +}); // ------ some apis for native ------ (window as any).getCurrentServerBaseUrl = () => { @@ -252,6 +289,9 @@ registerNativePreviewHandlers({ (window as any).getCurrentI18nLocale = () => { return I18n.language; }; +(window as any).getCurrentThemeMode = () => { + return 'system'; +}; (window as any).getAiButtonFeatureFlag = () => { const featureFlagService = frameworkProvider.get(FeatureFlagService); return featureFlagService.flags.enable_mobile_ai_button.value; @@ -522,6 +562,18 @@ const KeyboardThemeProvider = () => { }); }, [resolvedTheme]); + useEffect(() => { + const themeMode = resolvedTheme === 'dark' ? 'dark' : 'light'; + (window as any).getCurrentThemeMode = () => { + return themeMode; + }; + AffineTheme.onThemeChanged({ + themeMode, + }).catch(e => { + console.error(`Failed to sync app theme: ${e}`); + }); + }, [resolvedTheme]); + return null; }; diff --git a/packages/frontend/apps/ios/src/plugins/affine-theme/definitions.ts b/packages/frontend/apps/ios/src/plugins/affine-theme/definitions.ts new file mode 100644 index 0000000000..1fc1e0fea0 --- /dev/null +++ b/packages/frontend/apps/ios/src/plugins/affine-theme/definitions.ts @@ -0,0 +1,5 @@ +import type { NativeThemeMode } from './theme-mode'; + +export interface AffineThemePlugin { + onThemeChanged(options: { themeMode: NativeThemeMode }): Promise; +} diff --git a/packages/frontend/apps/ios/src/plugins/affine-theme/index.ts b/packages/frontend/apps/ios/src/plugins/affine-theme/index.ts new file mode 100644 index 0000000000..66b7dcd897 --- /dev/null +++ b/packages/frontend/apps/ios/src/plugins/affine-theme/index.ts @@ -0,0 +1,9 @@ +import { registerPlugin } from '@capacitor/core'; + +import type { AffineThemePlugin } from './definitions'; + +const AffineTheme = registerPlugin('AffineTheme'); + +export * from './definitions'; +export * from './theme-mode'; +export { AffineTheme }; diff --git a/packages/frontend/apps/ios/src/plugins/affine-theme/theme-mode.ts b/packages/frontend/apps/ios/src/plugins/affine-theme/theme-mode.ts new file mode 100644 index 0000000000..85b26bb292 --- /dev/null +++ b/packages/frontend/apps/ios/src/plugins/affine-theme/theme-mode.ts @@ -0,0 +1 @@ +export type NativeThemeMode = 'dark' | 'light' | 'system'; diff --git a/packages/frontend/apps/ios/src/plugins/image-picker/definitions.ts b/packages/frontend/apps/ios/src/plugins/image-picker/definitions.ts new file mode 100644 index 0000000000..dfee824cba --- /dev/null +++ b/packages/frontend/apps/ios/src/plugins/image-picker/definitions.ts @@ -0,0 +1,13 @@ +export interface PickedImageFile { + path: string; + name: string; + mimeType: string; + lastModified: number; +} + +export interface ImagePickerPlugin { + pickImages(options?: { multiple?: boolean }): Promise<{ + files: PickedImageFile[]; + canceled?: boolean; + }>; +} diff --git a/packages/frontend/apps/ios/src/plugins/image-picker/index.ts b/packages/frontend/apps/ios/src/plugins/image-picker/index.ts new file mode 100644 index 0000000000..3b06a9c831 --- /dev/null +++ b/packages/frontend/apps/ios/src/plugins/image-picker/index.ts @@ -0,0 +1,8 @@ +import { registerPlugin } from '@capacitor/core'; + +import type { ImagePickerPlugin } from './definitions'; + +const ImagePicker = registerPlugin('ImagePicker'); + +export * from './definitions'; +export { ImagePicker };