mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 10:06:17 +08:00
fix(ios): stabilize keyboard toolbar, image picker, and scrolling (#15182)
## Summary - keep the editor active when iOS keyboard toolbar interactions move focus into range-sync excluded widgets - use the native iOS image picker/source sheet and sync native presentation with the app theme - pin `ListViewKit` to `1.1.6` so the iOS workspace resolves with Xcode 16.3 - restore vertical scrolling in the iOS `WKWebView` by removing the global `contentOffset` reset while preserving zoom prevention ## Test plan - [x] `yarn vitest --run --config \"vitest.config.ts\" --browser.enabled=false \"src/__tests__/inline/active.unit.spec.ts\"` - [x] `LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 xcodebuild -resolvePackageDependencies -workspace \"packages/frontend/apps/ios/App/App.xcworkspace\" -scheme \"App\"` - [x] `xcodebuild -workspace \"App.xcworkspace\" -scheme \"App\" -destination \"generic/platform=iOS Simulator\" build CODE_SIGNING_ALLOWED=NO ONLY_ACTIVE_ARCH=YES ARCHS=arm64` - [x] Xcode build validation for the updated PR branch
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,14 @@ interface OpenFilePickerOptions {
|
||||
multiple?: boolean | undefined;
|
||||
}
|
||||
|
||||
export type NativeImageFilesPicker = () => Promise<File[] | null>;
|
||||
|
||||
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 ?? [];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
+2
-11
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PickedImageFile?>(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)
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<File> =>
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { NativeThemeMode } from './theme-mode';
|
||||
|
||||
export interface AffineThemePlugin {
|
||||
onThemeChanged(options: { themeMode: NativeThemeMode }): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { registerPlugin } from '@capacitor/core';
|
||||
|
||||
import type { AffineThemePlugin } from './definitions';
|
||||
|
||||
const AffineTheme = registerPlugin<AffineThemePlugin>('AffineTheme');
|
||||
|
||||
export * from './definitions';
|
||||
export * from './theme-mode';
|
||||
export { AffineTheme };
|
||||
@@ -0,0 +1 @@
|
||||
export type NativeThemeMode = 'dark' | 'light' | 'system';
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { registerPlugin } from '@capacitor/core';
|
||||
|
||||
import type { ImagePickerPlugin } from './definitions';
|
||||
|
||||
const ImagePicker = registerPlugin<ImagePickerPlugin>('ImagePicker');
|
||||
|
||||
export * from './definitions';
|
||||
export { ImagePicker };
|
||||
Reference in New Issue
Block a user