From cd50a0477067a6a8c50f96044f5d7515be5cf31e Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 22 Feb 2026 12:00:40 -0800 Subject: [PATCH] feat: paste input --- apps/demo/src/plugin-demos/paste-input.ts | 23 + apps/demo/src/plugin-demos/paste-input.xml | 44 ++ packages/paste-input/.eslintrc.json | 18 + packages/paste-input/README.md | 13 + packages/paste-input/angular/.eslintrc.json | 24 + packages/paste-input/angular/index.ts | 14 + packages/paste-input/angular/ng-package.json | 8 + packages/paste-input/angular/package.json | 3 + .../angular/paste-input.directive.ts | 17 + .../paste-input/angular/tsconfig.angular.json | 13 + packages/paste-input/angular/tsconfig.json | 7 + packages/paste-input/common.ts | 204 ++++++ packages/paste-input/index.android.ts | 380 ++++++++++ packages/paste-input/index.d.ts | 68 ++ packages/paste-input/index.ios.ts | 674 ++++++++++++++++++ packages/paste-input/package.json | 35 + packages/paste-input/project.json | 65 ++ packages/paste-input/references.d.ts | 1 + packages/paste-input/tsconfig.json | 9 + tools/demo/paste-input/index.ts | 40 ++ 20 files changed, 1660 insertions(+) create mode 100644 apps/demo/src/plugin-demos/paste-input.ts create mode 100644 apps/demo/src/plugin-demos/paste-input.xml create mode 100644 packages/paste-input/.eslintrc.json create mode 100644 packages/paste-input/README.md create mode 100644 packages/paste-input/angular/.eslintrc.json create mode 100644 packages/paste-input/angular/index.ts create mode 100644 packages/paste-input/angular/ng-package.json create mode 100644 packages/paste-input/angular/package.json create mode 100644 packages/paste-input/angular/paste-input.directive.ts create mode 100644 packages/paste-input/angular/tsconfig.angular.json create mode 100644 packages/paste-input/angular/tsconfig.json create mode 100644 packages/paste-input/common.ts create mode 100644 packages/paste-input/index.android.ts create mode 100644 packages/paste-input/index.d.ts create mode 100644 packages/paste-input/index.ios.ts create mode 100644 packages/paste-input/package.json create mode 100644 packages/paste-input/project.json create mode 100644 packages/paste-input/references.d.ts create mode 100644 packages/paste-input/tsconfig.json create mode 100644 tools/demo/paste-input/index.ts diff --git a/apps/demo/src/plugin-demos/paste-input.ts b/apps/demo/src/plugin-demos/paste-input.ts new file mode 100644 index 0000000..1df9a6e --- /dev/null +++ b/apps/demo/src/plugin-demos/paste-input.ts @@ -0,0 +1,23 @@ +import { EventData, Page, View } from '@nativescript/core'; +import { DemoSharedPasteInput } from '@demo/shared'; + +let model: DemoModel; + +export function navigatingTo(args: EventData) { + const page = args.object; + model = new DemoModel(); + page.bindingContext = model; +} + +export function onRemoveImage(args: EventData) { + const item = (args.object as View).bindingContext; + const idx = model.pastedImages.indexOf(item); + if (idx >= 0) { + model.pastedImages.splice(idx, 1); + if (model.pastedImages.length === 0) { + model.set('showImages', false); + } + } +} + +export class DemoModel extends DemoSharedPasteInput {} diff --git a/apps/demo/src/plugin-demos/paste-input.xml b/apps/demo/src/plugin-demos/paste-input.xml new file mode 100644 index 0000000..2047c7f --- /dev/null +++ b/apps/demo/src/plugin-demos/paste-input.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + diff --git a/packages/paste-input/.eslintrc.json b/packages/paste-input/.eslintrc.json new file mode 100644 index 0000000..53c06c8 --- /dev/null +++ b/packages/paste-input/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*", "node_modules/**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/packages/paste-input/README.md b/packages/paste-input/README.md new file mode 100644 index 0000000..a29d4a3 --- /dev/null +++ b/packages/paste-input/README.md @@ -0,0 +1,13 @@ +# @nativescript/paste-input + +```javascript +npm install @nativescript/paste-input +``` + +## Usage + +// TODO + +## License + +Apache License Version 2.0 diff --git a/packages/paste-input/angular/.eslintrc.json b/packages/paste-input/angular/.eslintrc.json new file mode 100644 index 0000000..e1b9056 --- /dev/null +++ b/packages/paste-input/angular/.eslintrc.json @@ -0,0 +1,24 @@ +{ + "extends": ["../.eslintrc.json"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": { + "@nx/enforce-module-boundaries": [ + "error", + { + "allowCircularSelfDependency": true + } + ] + } + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/packages/paste-input/angular/index.ts b/packages/paste-input/angular/index.ts new file mode 100644 index 0000000..76ae60d --- /dev/null +++ b/packages/paste-input/angular/index.ts @@ -0,0 +1,14 @@ +import { NgModule } from '@angular/core'; +import { registerElement } from '@nativescript/angular'; +import { PasteInput } from '@nativescript/paste-input'; +import { PasteInputDirective } from './paste-input.directive'; + +export * from './paste-input.directive'; + +@NgModule({ + declarations: [PasteInputDirective], + exports: [PasteInputDirective], +}) +export class PasteInputModule {} + +registerElement('PasteInput', () => PasteInput); diff --git a/packages/paste-input/angular/ng-package.json b/packages/paste-input/angular/ng-package.json new file mode 100644 index 0000000..982c547 --- /dev/null +++ b/packages/paste-input/angular/ng-package.json @@ -0,0 +1,8 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "index.ts" + }, + "allowedNonPeerDependencies": ["."], + "dest": "../../../dist/packages/paste-input/angular" +} diff --git a/packages/paste-input/angular/package.json b/packages/paste-input/angular/package.json new file mode 100644 index 0000000..263461f --- /dev/null +++ b/packages/paste-input/angular/package.json @@ -0,0 +1,3 @@ +{ + "name": "@nativescript/paste-input-angular" +} diff --git a/packages/paste-input/angular/paste-input.directive.ts b/packages/paste-input/angular/paste-input.directive.ts new file mode 100644 index 0000000..870f21f --- /dev/null +++ b/packages/paste-input/angular/paste-input.directive.ts @@ -0,0 +1,17 @@ +import { Directive, ElementRef, Inject, OnDestroy } from '@angular/core'; +import { PasteInput } from '@nativescript/paste-input'; + +@Directive({ + selector: 'PasteInput', +}) +export class PasteInputDirective implements OnDestroy { + private _pasteInput: PasteInput; + + constructor(@Inject(ElementRef) elementRef: ElementRef) { + this._pasteInput = elementRef.nativeElement; + } + + ngOnDestroy() { + this._pasteInput?.cleanupTempFiles(); + } +} diff --git a/packages/paste-input/angular/tsconfig.angular.json b/packages/paste-input/angular/tsconfig.angular.json new file mode 100644 index 0000000..88e92b9 --- /dev/null +++ b/packages/paste-input/angular/tsconfig.angular.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../node_modules/ng-packagr/lib/ts/conf/tsconfig.ngc.json", + "compilerOptions": { + "types": ["node"], + "baseUrl": ".", + "paths": { + "@nativescript/paste-input": ["../../../dist/packages/paste-input"] + }, + "outDir": "../../../dist/out-tsc", + "declarationDir": "../../../dist/out-tsc" + }, + "files": ["index.ts"] +} diff --git a/packages/paste-input/angular/tsconfig.json b/packages/paste-input/angular/tsconfig.json new file mode 100644 index 0000000..0ec69de --- /dev/null +++ b/packages/paste-input/angular/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "rootDirs": [".", "../.."] + } +} diff --git a/packages/paste-input/common.ts b/packages/paste-input/common.ts new file mode 100644 index 0000000..f3dc2f0 --- /dev/null +++ b/packages/paste-input/common.ts @@ -0,0 +1,204 @@ +import { EventData, Property, View, booleanConverter, knownFolders, path, Folder } from '@nativescript/core'; + +/** + * Payload types (discriminated union) + */ + +export interface PasteTextPayload { + type: 'text'; + value: string; +} + +export interface PasteImageItem { + uri: string; + mimeType: string; + width?: number; + height?: number; + animated: boolean; +} + +export interface PasteImagesPayload { + type: 'images'; + items: PasteImageItem[]; +} + +export interface PasteFileItem { + uri: string; + mimeType: string; + name?: string; + size?: number; +} + +export interface PasteFilesPayload { + type: 'files'; + items: PasteFileItem[]; +} + +export interface PasteUnsupportedPayload { + type: 'unsupported'; + availableTypes: string[]; +} + +export type PastePayload = PasteTextPayload | PasteImagesPayload | PasteFilesPayload | PasteUnsupportedPayload; + +export interface PasteEventData extends EventData { + data: PastePayload; +} + +export interface DropEventData extends EventData { + data: PastePayload; +} + +/** + * Temp file helpers + */ + +export class PasteInputTempFiles { + static getTempDir(): string { + return path.join(knownFolders.temp().path, 'paste-input'); + } + + static ensureTempDir(): string { + const dir = PasteInputTempFiles.getTempDir(); + const folder = Folder.fromPath(dir); + return folder.path; + } + + static generateFileName(extension: string): string { + const random = Math.random().toString(36).substring(2, 8); + return `paste-${Date.now()}-${random}.${extension}`; + } + + static generateFilePath(extension: string): string { + const dir = PasteInputTempFiles.ensureTempDir(); + return path.join(dir, PasteInputTempFiles.generateFileName(extension)); + } + + static getFileUri(filePath: string): string { + return 'file://' + filePath; + } + + static cleanupAll(): void { + try { + const folder = Folder.fromPath(PasteInputTempFiles.getTempDir()); + folder.clearSync(); + } catch (e) { + // temp dir may not exist yet + } + } +} + +/** + * MIME matching + */ + +export function mimeMatchesAccept(mime: string, accept: string): boolean { + if (!accept || accept === 'all') { + return true; + } + const acceptParts = accept.split(',').map((s) => s.trim().toLowerCase()); + const mimeLower = mime.toLowerCase(); + for (const part of acceptParts) { + if (part === 'all') return true; + if (part === 'text' && mimeLower.startsWith('text/')) return true; + if (part === 'image' && mimeLower.startsWith('image/')) return true; + if (part === mimeLower) return true; + // wildcard like image/* + if (part.endsWith('/*') && mimeLower.startsWith(part.replace('/*', '/'))) return true; + } + return false; +} + +/** + * UTI to MIME mapping (iOS) + */ + +export const utiToMime: Record = { + 'com.compuserve.gif': 'image/gif', + 'public.png': 'image/png', + 'public.jpeg': 'image/jpeg', + 'public.tiff': 'image/tiff', + 'com.microsoft.bmp': 'image/bmp', + 'public.heic': 'image/heic', + 'com.adobe.pdf': 'application/pdf', + 'public.rtf': 'application/rtf', + 'public.html': 'text/html', + 'public.plain-text': 'text/plain', + 'public.utf8-plain-text': 'text/plain', + 'public.url': 'text/uri-list', +}; + +/** + * Base class + */ + +export abstract class PasteInputBase extends View { + static pasteEvent = 'paste'; + static dropEvent = 'drop'; + + accept: string; + hint: string; + editable: boolean; + maxLength: number; + enableDragDrop: boolean; + + notifyPaste(payload: PastePayload) { + this.notify({ + eventName: PasteInputBase.pasteEvent, + object: this, + data: payload, + }); + } + + notifyDrop(payload: PastePayload) { + this.notify({ + eventName: PasteInputBase.dropEvent, + object: this, + data: payload, + }); + } + + abstract getText(): string; + abstract setText(value: string): void; + + cleanupTempFiles(): void { + PasteInputTempFiles.cleanupAll(); + } +} + +/** + * Property registrations + */ + +export const acceptProperty = new Property({ + name: 'accept', + defaultValue: 'all', +}); +acceptProperty.register(PasteInputBase); + +export const hintProperty = new Property({ + name: 'hint', + defaultValue: '', +}); +hintProperty.register(PasteInputBase); + +export const editableProperty = new Property({ + name: 'editable', + defaultValue: true, + valueConverter: booleanConverter, +}); +editableProperty.register(PasteInputBase); + +export const maxLengthProperty = new Property({ + name: 'maxLength', + defaultValue: 0, + valueConverter: (v) => parseInt(v, 10), +}); +maxLengthProperty.register(PasteInputBase); + +export const enableDragDropProperty = new Property({ + name: 'enableDragDrop', + defaultValue: false, + valueConverter: booleanConverter, +}); +enableDragDropProperty.register(PasteInputBase); diff --git a/packages/paste-input/index.android.ts b/packages/paste-input/index.android.ts new file mode 100644 index 0000000..e212c51 --- /dev/null +++ b/packages/paste-input/index.android.ts @@ -0,0 +1,380 @@ +import { PasteInputBase, PastePayload, PasteImageItem, PasteFileItem, PasteInputTempFiles, mimeMatchesAccept, hintProperty, editableProperty, maxLengthProperty, enableDragDropProperty } from './common'; + +/** + * Content processing helpers + */ + +function processClipDataItem(item: android.content.ClipData.Item, context: android.content.Context, accept: string): { images: PasteImageItem[]; files: PasteFileItem[]; text: string | null } { + const result = { images: [] as PasteImageItem[], files: [] as PasteFileItem[], text: null as string | null }; + const contentResolver = context.getContentResolver(); + + const uri = item.getUri(); + if (uri) { + const mimeType = contentResolver.getType(uri) || 'application/octet-stream'; + + // GIF - copy raw bytes to preserve animation + if (mimeType === 'image/gif' && mimeMatchesAccept('image/gif', accept)) { + const filePath = copyUriToTempFile(uri, contentResolver, 'gif'); + if (filePath) { + result.images.push({ + uri: PasteInputTempFiles.getFileUri(filePath), + mimeType: 'image/gif', + animated: true, + }); + } + return result; + } + + // Static images - decode and compress + if (mimeType.startsWith('image/') && mimeMatchesAccept(mimeType, accept)) { + const imageItem = decodeImageUri(uri, contentResolver, mimeType); + if (imageItem) { + result.images.push(imageItem); + } + return result; + } + + // Files/documents + if (mimeMatchesAccept(mimeType, accept)) { + const fileItem = copyFileUri(uri, contentResolver, context, mimeType); + if (fileItem) { + result.files.push(fileItem); + } + return result; + } + } + + // Text + const text = item.getText(); + if (text) { + const textStr = text.toString(); + if (textStr && mimeMatchesAccept('text/plain', accept)) { + result.text = textStr; + } + } else { + // Try coercing to text + const coerced = item.coerceToText(context); + if (coerced) { + const coercedStr = coerced.toString(); + if (coercedStr && mimeMatchesAccept('text/plain', accept)) { + result.text = coercedStr; + } + } + } + + return result; +} + +function decodeImageUri(uri: android.net.Uri, contentResolver: android.content.ContentResolver, mimeType: string): PasteImageItem | null { + let inputStream: java.io.InputStream = null; + try { + inputStream = contentResolver.openInputStream(uri); + if (!inputStream) return null; + + const bitmap = android.graphics.BitmapFactory.decodeStream(inputStream); + if (!bitmap) return null; + + const width = bitmap.getWidth(); + const height = bitmap.getHeight(); + + // Write as JPEG 80% (matching expo-paste-input) + const filePath = PasteInputTempFiles.generateFilePath('jpg'); + const file = new java.io.File(filePath); + const outputStream = new java.io.FileOutputStream(file); + bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 80, outputStream); + outputStream.flush(); + outputStream.close(); + bitmap.recycle(); + + return { + uri: PasteInputTempFiles.getFileUri(filePath), + mimeType: mimeType.startsWith('image/') ? mimeType : 'image/jpeg', + width, + height, + animated: false, + }; + } catch (e) { + return null; + } finally { + if (inputStream) { + try { + inputStream.close(); + } catch (_e) { + // ignore + } + } + } +} + +function copyUriToTempFile(uri: android.net.Uri, contentResolver: android.content.ContentResolver, extension: string): string | null { + let inputStream: java.io.InputStream = null; + let outputStream: java.io.FileOutputStream = null; + try { + inputStream = contentResolver.openInputStream(uri); + if (!inputStream) return null; + + const filePath = PasteInputTempFiles.generateFilePath(extension); + const file = new java.io.File(filePath); + outputStream = new java.io.FileOutputStream(file); + + const buffer = Array.create('byte', 8192); + let bytesRead: number; + while ((bytesRead = inputStream.read(buffer)) !== -1) { + outputStream.write(buffer, 0, bytesRead); + } + outputStream.flush(); + return filePath; + } catch (e) { + return null; + } finally { + if (inputStream) { + try { + inputStream.close(); + } catch (_e) { + /* ignore */ + } + } + if (outputStream) { + try { + outputStream.close(); + } catch (_e) { + /* ignore */ + } + } + } +} + +function copyFileUri(uri: android.net.Uri, contentResolver: android.content.ContentResolver, _context: android.content.Context, mimeType: string): PasteFileItem | null { + const ext = mimeToExtension(mimeType); + const filePath = copyUriToTempFile(uri, contentResolver, ext); + if (!filePath) return null; + + let name: string | undefined; + let size: number | undefined; + + // Query for display name and size + let cursor: android.database.Cursor = null; + try { + cursor = contentResolver.query(uri, null, null, null, null); + if (cursor && cursor.moveToFirst()) { + const nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME); + if (nameIndex >= 0) { + name = cursor.getString(nameIndex); + } + const sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE); + if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) { + size = cursor.getLong(sizeIndex); + } + } + } catch (_e) { + // ignore query errors + } finally { + if (cursor) { + cursor.close(); + } + } + + return { + uri: PasteInputTempFiles.getFileUri(filePath), + mimeType, + name, + size, + }; +} + +function mimeToExtension(mime: string): string { + const map: Record = { + 'application/pdf': 'pdf', + 'application/rtf': 'rtf', + 'text/html': 'html', + 'text/plain': 'txt', + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + }; + return map[mime] || 'bin'; +} + +function buildPayloadFromClipData(clip: android.content.ClipData, context: android.content.Context, accept: string): PastePayload | null { + const allImages: PasteImageItem[] = []; + const allFiles: PasteFileItem[] = []; + let textValue: string | null = null; + + const itemCount = clip.getItemCount(); + for (let i = 0; i < itemCount; i++) { + const item = clip.getItemAt(i); + const processed = processClipDataItem(item, context, accept); + allImages.push(...processed.images); + allFiles.push(...processed.files); + if (processed.text && !textValue) { + textValue = processed.text; + } + } + + if (allImages.length > 0) { + return { type: 'images', items: allImages }; + } + if (allFiles.length > 0) { + return { type: 'files', items: allFiles }; + } + if (textValue) { + return { type: 'text', value: textValue }; + } + + // Gather available MIME types for unsupported payload + const availableTypes: string[] = []; + const desc = clip.getDescription(); + if (desc) { + const mimeCount = desc.getMimeTypeCount(); + for (let i = 0; i < mimeCount; i++) { + availableTypes.push(desc.getMimeType(i)); + } + } + if (availableTypes.length > 0) { + return { type: 'unsupported', availableTypes }; + } + + return null; +} + +/** + * PasteInput + */ + +export class PasteInput extends PasteInputBase { + nativeViewProtected: androidx.appcompat.widget.AppCompatEditText; + + createNativeView(): android.widget.EditText { + const editText = new androidx.appcompat.widget.AppCompatEditText(this._context); + + if (android.os.Build.VERSION.SDK_INT >= 31) { + this._setupReceiveContentListener(editText); + } else { + this._setupLegacyPasteInterception(editText); + } + + return editText; + } + + initNativeView(): void { + super.initNativeView(); + } + + disposeNativeView(): void { + super.disposeNativeView(); + } + + /** + * API 31+ : OnReceiveContentListener + */ + + private _setupReceiveContentListener(editText: androidx.appcompat.widget.AppCompatEditText): void { + const ownerRef = new WeakRef(this); + const mimeTypes = ['image/*', 'text/*', 'application/pdf', 'application/*', '*/*']; + + const listener = new androidx.core.view.OnReceiveContentListener({ + onReceiveContent(view: android.view.View, payload: androidx.core.view.ContentInfoCompat): androidx.core.view.ContentInfoCompat { + const owner = ownerRef.deref(); + if (!owner) return payload; + + const clip = payload.getClip(); + if (!clip || clip.getItemCount() === 0) return payload; + + const accept = owner.accept || 'all'; + const source = payload.getSource(); + const pastePayload = buildPayloadFromClipData(clip, view.getContext(), accept); + + if (pastePayload) { + if (pastePayload.type === 'text') { + // Let text paste through by returning the payload to the system + owner.notifyPaste(pastePayload); + return payload; + } + // For images/files, we handle it and return null (consumed) + if (source === androidx.core.view.ContentInfoCompat.SOURCE_DRAG_AND_DROP) { + owner.notifyDrop(pastePayload); + } else { + owner.notifyPaste(pastePayload); + } + return null; // consumed + } + + return payload; // unhandled, pass through + }, + }); + + androidx.core.view.ViewCompat.setOnReceiveContentListener(editText, mimeTypes, listener); + } + + /** + * Pre-API 31 fallback + */ + + private _setupLegacyPasteInterception(editText: androidx.appcompat.widget.AppCompatEditText): void { + const ownerRef = new WeakRef(this); + + editText.onTextContextMenuItem = (id: number): boolean => { + const result = (androidx.appcompat.widget.AppCompatEditText.prototype as any).onTextContextMenuItem.call(editText, id); + + if (id === android.R.id.paste) { + const owner = ownerRef.deref(); + if (owner) { + const clipboard = owner._context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager; + + if (clipboard?.hasPrimaryClip()) { + const clip = clipboard.getPrimaryClip(); + const accept = owner.accept || 'all'; + const payload = buildPayloadFromClipData(clip, owner._context, accept); + + if (payload) { + owner.notifyPaste(payload); + } + } + } + } + + return result; + }; + } + + /** + * Property handlers + */ + + [hintProperty.setNative](value: string) { + this.nativeViewProtected.setHint(value); + } + + [editableProperty.setNative](value: boolean) { + this.nativeViewProtected.setFocusable(value); + this.nativeViewProtected.setFocusableInTouchMode(value); + } + + [maxLengthProperty.setNative](value: number) { + if (value > 0) { + this.nativeViewProtected.setFilters([new android.text.InputFilter.LengthFilter(value)]); + } else { + this.nativeViewProtected.setFilters([]); + } + } + + [enableDragDropProperty.setNative](_value: boolean) { + // On API 31+, OnReceiveContentListener already handles drag & drop + // On older APIs, drag & drop support is limited + } + + /** + * Public methods + */ + + getText(): string { + return this.nativeViewProtected?.getText()?.toString() || ''; + } + + setText(value: string): void { + if (this.nativeViewProtected) { + this.nativeViewProtected.setText(value); + } + } +} diff --git a/packages/paste-input/index.d.ts b/packages/paste-input/index.d.ts new file mode 100644 index 0000000..6eaeaaf --- /dev/null +++ b/packages/paste-input/index.d.ts @@ -0,0 +1,68 @@ +import { EventData, View } from '@nativescript/core'; + +export interface PasteTextPayload { + type: 'text'; + value: string; +} + +export interface PasteImageItem { + uri: string; + mimeType: string; + width?: number; + height?: number; + animated: boolean; +} + +export interface PasteImagesPayload { + type: 'images'; + items: PasteImageItem[]; +} + +export interface PasteFileItem { + uri: string; + mimeType: string; + name?: string; + size?: number; +} + +export interface PasteFilesPayload { + type: 'files'; + items: PasteFileItem[]; +} + +export interface PasteUnsupportedPayload { + type: 'unsupported'; + availableTypes: string[]; +} + +export type PastePayload = PasteTextPayload | PasteImagesPayload | PasteFilesPayload | PasteUnsupportedPayload; + +export interface PasteEventData extends EventData { + data: PastePayload; +} + +export interface DropEventData extends EventData { + data: PastePayload; +} + +export declare class PasteInput extends View { + static pasteEvent: string; + static dropEvent: string; + + readonly android: any; + readonly ios: any; + + accept: string; + hint: string; + editable: boolean; + maxLength: number; + enableDragDrop: boolean; + + on(eventName: 'paste', callback: (args: PasteEventData) => void, thisArg?: any): void; + on(eventName: 'drop', callback: (args: DropEventData) => void, thisArg?: any): void; + on(eventName: string, callback: (args: EventData) => void, thisArg?: any): void; + + getText(): string; + setText(value: string): void; + cleanupTempFiles(): void; +} diff --git a/packages/paste-input/index.ios.ts b/packages/paste-input/index.ios.ts new file mode 100644 index 0000000..424e2d4 --- /dev/null +++ b/packages/paste-input/index.ios.ts @@ -0,0 +1,674 @@ +import { Utils } from '@nativescript/core'; +import { PasteInputBase, PastePayload, PasteImageItem, PasteFileItem, PasteInputTempFiles, mimeMatchesAccept, utiToMime, hintProperty, editableProperty, maxLengthProperty, enableDragDropProperty } from './common'; + +/** + * UITextView delegate + */ + +@NativeClass() +class NSPasteTextViewDelegate extends NSObject implements UITextViewDelegate { + static ObjCProtocols = [UITextViewDelegate]; + + owner: WeakRef; + + static initWithOwner(owner: WeakRef): NSPasteTextViewDelegate { + const delegate = NSPasteTextViewDelegate.new() as NSPasteTextViewDelegate; + delegate.owner = owner; + return delegate; + } + + textViewDidChange(_textView: UITextView): void { + const owner = this.owner?.deref(); + if (owner) { + owner._updatePlaceholderVisibility(); + } + } + + textViewShouldChangeTextInRangeReplacementText(textView: UITextView, range: NSRange, text: string): boolean { + const owner = this.owner?.deref(); + if (owner && owner.maxLength > 0) { + const currentLength = textView.text?.length || 0; + const rangeLength = range.length; + const newLength = currentLength - rangeLength + text.length; + return newLength <= owner.maxLength; + } + return true; + } +} + +/** + * UIDropInteraction delegate + */ + +@NativeClass() +class NSPasteDropDelegate extends NSObject implements UIDropInteractionDelegate { + static ObjCProtocols = [UIDropInteractionDelegate]; + + owner: WeakRef; + + static initWithOwner(owner: WeakRef): NSPasteDropDelegate { + const delegate = NSPasteDropDelegate.new() as NSPasteDropDelegate; + delegate.owner = owner; + return delegate; + } + + dropInteractionCanHandleSession(_interaction: UIDropInteraction, session: UIDropSession): boolean { + const owner = this.owner?.deref(); + if (!owner) return false; + const accept = owner.accept || 'all'; + if (accept === 'all') return true; + return session.hasItemsConformingToTypeIdentifiers(['public.image', 'public.data', 'public.text']); + } + + dropInteractionSessionDidUpdate(_interaction: UIDropInteraction, _session: UIDropSession): UIDropProposal { + return UIDropProposal.alloc().initWithDropOperation(UIDropOperation.Copy); + } + + dropInteractionPerformDrop(_interaction: UIDropInteraction, session: UIDropSession): void { + const owner = this.owner?.deref(); + if (!owner) return; + + const itemCount = session.items.count; + const imageItems: PasteImageItem[] = []; + let pendingLoads = 0; + + for (let i = 0; i < itemCount; i++) { + const dragItem = session.items.objectAtIndex(i); + const provider = dragItem.itemProvider; + + if (provider.hasItemConformingToTypeIdentifier('com.compuserve.gif')) { + pendingLoads++; + provider.loadDataRepresentationForTypeIdentifierCompletionHandler('com.compuserve.gif', (data: NSData, _error: NSError) => { + if (data) { + const filePath = PasteInputTempFiles.generateFilePath('gif'); + data.writeToFileAtomically(filePath, true); + imageItems.push({ + uri: PasteInputTempFiles.getFileUri(filePath), + mimeType: 'image/gif', + animated: true, + }); + } + pendingLoads--; + if (pendingLoads === 0 && imageItems.length > 0) { + Utils.executeOnMainThread(() => { + owner.notifyDrop({ type: 'images', items: imageItems }); + }); + } + }); + } else if (provider.canLoadObjectOfClass(UIImage.class())) { + pendingLoads++; + provider.loadObjectOfClassCompletionHandler(UIImage.class(), (object: UIImage, _error: NSError) => { + if (object) { + const item = writeImageToTemp(object); + if (item) { + imageItems.push(item); + } + } + pendingLoads--; + if (pendingLoads === 0 && imageItems.length > 0) { + Utils.executeOnMainThread(() => { + owner.notifyDrop({ type: 'images', items: imageItems }); + }); + } + }); + } + } + } +} + +/** + * UITextView subclass + */ + +@NativeClass() +class NSPasteTextView extends UITextView { + owner: WeakRef; + + static initWithOwner(owner: WeakRef): NSPasteTextView { + const view = NSPasteTextView.new() as NSPasteTextView; + view.owner = owner; + return view; + } + + override canPerformActionWithSender(action: string, sender: any): boolean { + if (action === 'paste:') { + const clipboard = UIPasteboard.generalPasteboard; + // Show Paste for images, files, URLs, and text — not just text + if (clipboard.hasImages || clipboard.hasStrings || clipboard.hasURLs || clipboard.numberOfItems > 0) { + return true; + } + } + return super.canPerformActionWithSender(action, sender); + } + + override paste(sender: any): void { + const owner = this.owner?.deref(); + if (!owner) { + super.paste(sender); + return; + } + + const clipboard = UIPasteboard.generalPasteboard; + const accept = owner.accept || 'all'; + const payload = extractPasteboardContent(clipboard, accept); + + if (payload) { + if (payload.type === 'text') { + // Let text paste through normally, then notify + super.paste(sender); + } + // For images/files/unsupported, do NOT call super.paste() + owner.notifyPaste(payload); + } else { + super.paste(sender); + } + } +} + +/** + * Pasteboard content extraction + */ + +function acceptsImages(accept: string): boolean { + return !accept || accept === 'all' || mimeMatchesAccept('image/png', accept) || mimeMatchesAccept('image/jpeg', accept) || mimeMatchesAccept('image/gif', accept); +} + +function acceptsText(accept: string): boolean { + return !accept || accept === 'all' || mimeMatchesAccept('text/plain', accept); +} + +function acceptsFiles(accept: string): boolean { + return !accept || accept === 'all' || mimeMatchesAccept('application/pdf', accept) || mimeMatchesAccept('application/rtf', accept) || mimeMatchesAccept('text/html', accept); +} + +/** + * Extension-to-MIME map for resolving file URLs + */ +const imageExtensions: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + heic: 'image/heic', + heif: 'image/heif', + webp: 'image/webp', + tiff: 'image/tiff', + tif: 'image/tiff', + bmp: 'image/bmp', + ico: 'image/x-icon', + icns: 'image/x-icns', +}; + +/** + * Process a resolved file URL into a PastePayload. + * For file reference URLs (/.file/id=...), uses NSData.dataWithContentsOfURL as fallback. + * hintFilename is used when the URL path has no extension (e.g. clipboard.string from Finder). + */ +function processFileUrl(fileUrl: NSURL, accept: string, hintFilename?: string): PastePayload | null { + const filePath = fileUrl.path; + const isFileRef = !filePath || filePath.startsWith('/.file/'); + + // Determine extension from path or hint filename (clipboard.string from Finder) + let ext = ''; + if (!isFileRef && filePath) { + ext = filePath.split('.').pop()?.toLowerCase() || ''; + } + if (!ext && hintFilename) { + const dotIndex = hintFilename.lastIndexOf('.'); + if (dotIndex >= 0) { + ext = hintFilename.substring(dotIndex + 1).toLowerCase(); + } + } + + const imageMime = imageExtensions[ext]; + + // Load data from file path (resolved URL) or directly from URL (file reference) + const loadData = (): NSData | null => { + if (!isFileRef && filePath) { + return NSData.dataWithContentsOfFile(filePath); + } + try { + return NSData.dataWithContentsOfURL(fileUrl); + } catch (_e) { + return null; + } + }; + + // Load UIImage from file path or from data + const loadImage = (): UIImage | null => { + if (!isFileRef && filePath) { + return UIImage.imageWithContentsOfFile(filePath); + } + const data = loadData(); + return data ? UIImage.imageWithData(data) : null; + }; + + // Try as image if it has a known image extension + if (imageMime && acceptsImages(accept)) { + if (ext === 'gif') { + const data = loadData(); + if (data) { + const tempPath = PasteInputTempFiles.generateFilePath('gif'); + data.writeToFileAtomically(tempPath, true); + return { + type: 'images', + items: [{ uri: PasteInputTempFiles.getFileUri(tempPath), mimeType: 'image/gif', animated: true }], + }; + } + } + + const image = loadImage(); + if (image) { + const item = writeImageToTemp(image); + if (item) { + return { type: 'images', items: [item] }; + } + } + } + + // No known image extension — try loading as image anyway + if (!imageMime && acceptsImages(accept)) { + const image = loadImage(); + if (image) { + const item = writeImageToTemp(image); + if (item) { + return { type: 'images', items: [item] }; + } + } + } + + // Try as a generic file + if (acceptsFiles(accept)) { + const data = loadData(); + if (data) { + const tempPath = PasteInputTempFiles.generateFilePath(ext || 'bin'); + data.writeToFileAtomically(tempPath, true); + const fileName = !isFileRef && filePath ? filePath.split('/').pop() : hintFilename || undefined; + return { + type: 'files', + items: [{ uri: PasteInputTempFiles.getFileUri(tempPath), mimeType: imageMime || 'application/octet-stream', name: fileName, size: data.length }], + }; + } + } + + return null; +} + +/** + * URL resolution helpers + */ + +function resolveToPathUrl(url: NSURL): NSURL | null { + if (!url) return null; + + const path = url.path; + if (path && !path.startsWith('/.file/')) { + return url; + } + + // filePathURL resolves file reference URLs to path-based file URLs + const pathUrl = url.filePathURL; + if (pathUrl && pathUrl.path && !pathUrl.path.startsWith('/.file/')) { + return pathUrl; + } + + return null; +} + +function resolveBookmarkData(data: NSData): NSURL | null { + try { + const isStale = new interop.Reference(); + const resolved = NSURL.URLByResolvingBookmarkDataOptionsRelativeToURLBookmarkDataIsStaleError(data, NSURLBookmarkResolutionOptions.WithoutUI, null, isStale); + if (resolved && resolved.path && !resolved.path.startsWith('/.file/')) { + return resolved; + } + } catch (_e) { + // Not valid bookmark data or resolution failed + } + return null; +} + +/** + * Extract a usable file URL from the pasteboard. + * Tries multiple strategies: direct URL, bookmark/alias resolution, URL data decoding. + * Note: macOS Finder file reference URLs (file:///.file/id=...) cannot be resolved + * on the iOS Simulator — this is a known simulator limitation. On real devices, + * file URLs from the Files app or other iOS sources use resolvable paths. + */ +function extractFileUrl(clipboard: UIPasteboard): NSURL | null { + // Try clipboard.URL first + const directUrl = clipboard.URL; + if (directUrl && directUrl.fileURL) { + const resolved = resolveToPathUrl(directUrl); + if (resolved) return resolved; + } + + const itemCount = clipboard.items?.count || 0; + if (itemCount === 0) return null; + + const itemDict = clipboard.items.objectAtIndex(0) as NSDictionary; + + // Try com.apple.finder.noderef as bookmark/alias data (contains embedded file path) + const noderefData = itemDict.objectForKey('com.apple.finder.noderef') as NSData; + if (noderefData) { + const resolved = resolveBookmarkData(noderefData); + if (resolved) return resolved; + } + + // Try public.file-url data + const fileUrlData = itemDict.objectForKey('public.file-url') as NSData; + if (fileUrlData) { + // Try as bookmark data first (may contain embedded path) + const bookmarkResolved = resolveBookmarkData(fileUrlData); + if (bookmarkResolved) return bookmarkResolved; + + // Decode as URL data representation + try { + const url = NSURL.URLWithDataRepresentationRelativeToURL(fileUrlData, null); + if (url) { + const resolved = resolveToPathUrl(url); + if (resolved) return resolved; + // Return unresolved URL — processFileUrl can try data-based loading + return url; + } + } catch (_e) { + // ignore + } + } + + return null; +} + +function extractPasteboardContent(clipboard: UIPasteboard, accept: string): PastePayload | null { + const wantsImages = acceptsImages(accept); + const wantsText = acceptsText(accept); + const wantsFiles = acceptsFiles(accept); + + // 1. GIF check first (preserves animation, must come before image check) + if (wantsImages) { + const gifData = clipboard.dataForPasteboardType('com.compuserve.gif'); + if (gifData) { + const filePath = PasteInputTempFiles.generateFilePath('gif'); + gifData.writeToFileAtomically(filePath, true); + + const imageItem: PasteImageItem = { + uri: PasteInputTempFiles.getFileUri(filePath), + mimeType: 'image/gif', + animated: true, + }; + + const items: PasteImageItem[] = [imageItem]; + const itemCount = clipboard.items?.count || 0; + for (let i = 1; i < itemCount; i++) { + const itemDict = clipboard.items.objectAtIndex(i) as NSDictionary; + const extraGifData = itemDict.objectForKey('com.compuserve.gif') as NSData; + if (extraGifData) { + const extraPath = PasteInputTempFiles.generateFilePath('gif'); + extraGifData.writeToFileAtomically(extraPath, true); + items.push({ + uri: PasteInputTempFiles.getFileUri(extraPath), + mimeType: 'image/gif', + animated: true, + }); + } + } + return { type: 'images', items }; + } + } + + // 2. Static images from pasteboard image data + if (wantsImages && clipboard.hasImages) { + const images = clipboard.images; + if (images && images.count > 0) { + const items: PasteImageItem[] = []; + for (let i = 0; i < images.count; i++) { + const image = images.objectAtIndex(i); + const item = writeImageToTemp(image); + if (item) { + items.push(item); + } + } + if (items.length > 0) { + return { type: 'images', items }; + } + } + } + + // 3. File URLs — e.g. files copied from Files app or Finder + if (wantsImages || wantsFiles) { + const fileUrl = extractFileUrl(clipboard); + if (fileUrl) { + const result = processFileUrl(fileUrl, accept, clipboard.string); + if (result) return result; + } + } + + // 4. Files/documents from pasteboard UTI types + if (wantsFiles) { + const fileTypes = ['com.adobe.pdf', 'public.rtf', 'public.html']; + for (const uti of fileTypes) { + const mime = utiToMime[uti] || 'application/octet-stream'; + if (mimeMatchesAccept(mime, accept)) { + const data = clipboard.dataForPasteboardType(uti); + if (data) { + const ext = mimeToExtension(mime); + const filePath = PasteInputTempFiles.generateFilePath(ext); + data.writeToFileAtomically(filePath, true); + + const fileItem: PasteFileItem = { + uri: PasteInputTempFiles.getFileUri(filePath), + mimeType: mime, + size: data.length, + }; + return { type: 'files', items: [fileItem] }; + } + } + } + } + + // 5. Text + if (wantsText && clipboard.hasStrings) { + const text = clipboard.string; + if (text) { + return { type: 'text', value: text }; + } + } + + // 6. Unsupported - gather available types from pasteboard items + const availableTypes: string[] = []; + const itemCount = clipboard.items?.count || 0; + for (let i = 0; i < itemCount; i++) { + const itemDict = clipboard.items.objectAtIndex(i) as NSDictionary; + const keys = itemDict.allKeys; + for (let k = 0; k < keys.count; k++) { + const uti = keys.objectAtIndex(k) as string; + const mime = utiToMime[uti] || uti; + if (availableTypes.indexOf(mime) === -1) { + availableTypes.push(mime); + } + } + } + if (availableTypes.length > 0) { + return { type: 'unsupported', availableTypes }; + } + + return null; +} + +/** + * Image writing helpers + */ + +function writeImageToTemp(image: UIImage): PasteImageItem | null { + if (!image) return null; + + const hasAlpha = hasAlphaChannel(image); + let data: NSData; + let mimeType: string; + let ext: string; + + if (hasAlpha) { + data = UIImagePNGRepresentation(image); + mimeType = 'image/png'; + ext = 'png'; + } else { + data = UIImageJPEGRepresentation(image, 0.8); + mimeType = 'image/jpeg'; + ext = 'jpg'; + } + + if (!data) return null; + + const filePath = PasteInputTempFiles.generateFilePath(ext); + data.writeToFileAtomically(filePath, true); + + return { + uri: PasteInputTempFiles.getFileUri(filePath), + mimeType, + width: image.size.width, + height: image.size.height, + animated: false, + }; +} + +function hasAlphaChannel(image: UIImage): boolean { + if (!image.CGImage) return false; + const alphaInfo = CGImageGetAlphaInfo(image.CGImage); + return alphaInfo !== CGImageAlphaInfo.kCGImageAlphaNone && alphaInfo !== CGImageAlphaInfo.kCGImageAlphaNoneSkipLast && alphaInfo !== CGImageAlphaInfo.kCGImageAlphaNoneSkipFirst; +} + +function mimeToExtension(mime: string): string { + const map: Record = { + 'application/pdf': 'pdf', + 'application/rtf': 'rtf', + 'text/html': 'html', + 'text/plain': 'txt', + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/heic': 'heic', + }; + return map[mime] || 'bin'; +} + +/** + * PasteInput + */ + +export class PasteInput extends PasteInputBase { + nativeViewProtected: NSPasteTextView; + private _delegate: NSPasteTextViewDelegate; + private _placeholderLabel: UILabel; + private _dropInteraction: UIDropInteraction; + private _dropDelegate: NSPasteDropDelegate; + + createNativeView(): UITextView { + const view = NSPasteTextView.initWithOwner(new WeakRef(this)); + view.font = UIFont.systemFontOfSize(16); + view.textContainerInset = UIEdgeInsetsZero; + view.textContainer.lineFragmentPadding = 0; + return view; + } + + initNativeView(): void { + super.initNativeView(); + this._delegate = NSPasteTextViewDelegate.initWithOwner(new WeakRef(this)); + this.nativeViewProtected.delegate = this._delegate; + this._setupPlaceholder(); + } + + disposeNativeView(): void { + this._removeDragDrop(); + this.nativeViewProtected.delegate = null; + this._delegate = null; + this._placeholderLabel = null; + super.disposeNativeView(); + } + + /** + * Property handlers + */ + + [hintProperty.setNative](value: string) { + if (this._placeholderLabel) { + this._placeholderLabel.text = value; + } + } + + [editableProperty.setNative](value: boolean) { + this.nativeViewProtected.editable = value; + } + + [maxLengthProperty.setNative](_value: number) { + // Enforced via delegate textViewShouldChangeTextInRangeReplacementText + } + + [enableDragDropProperty.setNative](value: boolean) { + if (value) { + this._setupDragDrop(); + } else { + this._removeDragDrop(); + } + } + + /** + * Public methods + */ + + getText(): string { + return this.nativeViewProtected?.text || ''; + } + + setText(value: string): void { + if (this.nativeViewProtected) { + this.nativeViewProtected.text = value; + this._updatePlaceholderVisibility(); + } + } + + /** + * Placeholder + */ + + private _setupPlaceholder(): void { + const label = UILabel.alloc().init(); + label.font = this.nativeViewProtected.font; + label.textColor = UIColor.placeholderTextColor; + label.numberOfLines = 0; + label.translatesAutoresizingMaskIntoConstraints = false; + this.nativeViewProtected.addSubview(label); + + const insets = this.nativeViewProtected.textContainerInset; + const padding = this.nativeViewProtected.textContainer.lineFragmentPadding; + + NSLayoutConstraint.activateConstraints(NSArray.arrayWithArray([label.topAnchor.constraintEqualToAnchorConstant(this.nativeViewProtected.topAnchor, insets.top), label.leadingAnchor.constraintEqualToAnchorConstant(this.nativeViewProtected.leadingAnchor, insets.left + padding), label.trailingAnchor.constraintEqualToAnchorConstant(this.nativeViewProtected.trailingAnchor, -(insets.right + padding))])); + + this._placeholderLabel = label; + this._updatePlaceholderVisibility(); + } + + _updatePlaceholderVisibility(): void { + if (this._placeholderLabel) { + this._placeholderLabel.hidden = this.nativeViewProtected.text?.length > 0; + } + } + + /** + * Drag & drop + */ + + private _setupDragDrop(): void { + if (this._dropInteraction) return; + this._dropDelegate = NSPasteDropDelegate.initWithOwner(new WeakRef(this)); + this._dropInteraction = UIDropInteraction.alloc().initWithDelegate(this._dropDelegate); + this.nativeViewProtected.addInteraction(this._dropInteraction); + } + + private _removeDragDrop(): void { + if (this._dropInteraction) { + this.nativeViewProtected.removeInteraction(this._dropInteraction); + this._dropInteraction = null; + this._dropDelegate = null; + } + } +} diff --git a/packages/paste-input/package.json b/packages/paste-input/package.json new file mode 100644 index 0000000..75a8ad8 --- /dev/null +++ b/packages/paste-input/package.json @@ -0,0 +1,35 @@ +{ + "name": "@nativescript/paste-input", + "version": "1.0.0", + "description": "Rich paste handling in input fields for NativeScript.", + "main": "index", + "types": "index.d.ts", + "nativescript": { + "platforms": { + "ios": "6.0.0", + "android": "6.0.0" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/NativeScript/ui-kit.git" + }, + "keywords": [ + "NativeScript", + "JavaScript", + "TypeScript", + "iOS", + "Android" + ], + "author": { + "name": "NativeScript", + "email": "oss@nativescript.org" + }, + "bugs": { + "url": "https://github.com/NativeScript/ui-kit/issues" + }, + "license": "Apache-2.0", + "homepage": "https://github.com/NativeScript/ui-kit", + "readmeFilename": "README.md", + "bootstrapper": "@nativescript/plugin-seed" +} diff --git a/packages/paste-input/project.json b/packages/paste-input/project.json new file mode 100644 index 0000000..69f6274 --- /dev/null +++ b/packages/paste-input/project.json @@ -0,0 +1,65 @@ +{ + "name": "paste-input", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "packages/paste-input", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "options": { + "outputPath": "dist/packages/paste-input", + "tsConfig": "packages/paste-input/tsconfig.json", + "packageJson": "packages/paste-input/package.json", + "main": "packages/paste-input/index.d.ts", + "assets": [ + "packages/paste-input/*.md", + "packages/paste-input/index.d.ts", + "LICENSE", + { + "glob": "**/*", + "input": "packages/paste-input/platforms/", + "output": "./platforms/" + } + ], + "dependsOn": [ + { + "target": "build.all", + "projects": "dependencies" + } + ] + } + }, + "build.all": { + "executor": "nx:run-commands", + "options": { + "commands": ["node tools/scripts/build-finish.ts paste-input"], + "parallel": false + }, + "outputs": ["{workspaceRoot}/dist/packages/paste-input"], + "dependsOn": [ + { + "target": "build.all", + "projects": "dependencies" + }, + { + "target": "build", + "projects": "self" + } + ] + }, + "focus": { + "executor": "nx:run-commands", + "options": { + "commands": ["nx g @nativescript/plugin-tools:focus-packages paste-input"], + "parallel": false + } + }, + "lint": { + "executor": "@nx/eslint:eslint", + "options": { + "lintFilePatterns": ["packages/paste-input/**/*.ts"] + } + } + }, + "tags": [] +} diff --git a/packages/paste-input/references.d.ts b/packages/paste-input/references.d.ts new file mode 100644 index 0000000..22bac92 --- /dev/null +++ b/packages/paste-input/references.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/paste-input/tsconfig.json b/packages/paste-input/tsconfig.json new file mode 100644 index 0000000..aed7323 --- /dev/null +++ b/packages/paste-input/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "rootDir": "." + }, + "exclude": ["**/*.spec.ts", "**/*.test.ts", "angular"], + "include": ["**/*.ts", "references.d.ts"] +} diff --git a/tools/demo/paste-input/index.ts b/tools/demo/paste-input/index.ts new file mode 100644 index 0000000..9c01e6c --- /dev/null +++ b/tools/demo/paste-input/index.ts @@ -0,0 +1,40 @@ +import { ObservableArray } from '@nativescript/core'; +import { DemoSharedBase } from '../utils'; +import { PasteEventData, PasteImageItem } from '@nativescript/paste-input'; + +export class DemoSharedPasteInput extends DemoSharedBase { + pasteResult: string = ''; + showImages: boolean = false; + pastedImages: ObservableArray = new ObservableArray(); + + onPaste(args: PasteEventData) { + const payload = args.data; + switch (payload.type) { + case 'text': + this.set('pasteResult', `Text: ${payload.value}`); + console.log('[PasteInput] Text pasted:', payload.value); + break; + case 'images': + const imgInfo = payload.items.map((i) => ` ${i.mimeType} ${i.width || '?'}x${i.height || '?'} animated:${i.animated}`).join('\n'); + this.set('pasteResult', `Images (${payload.items.length}):\n${imgInfo}`); + this.pastedImages.push(...payload.items); + this.set('showImages', true); + console.log('[PasteInput] Images pasted:', payload.items.length); + break; + case 'files': + const fileInfo = payload.items.map((f) => ` ${f.name || 'unknown'} (${f.mimeType}, ${f.size || '?'} bytes)`).join('\n'); + this.set('pasteResult', `Files (${payload.items.length}):\n${fileInfo}`); + console.log('[PasteInput] Files pasted:', payload.items.length); + break; + case 'unsupported': + this.set('pasteResult', `Unsupported. Available types:\n ${payload.availableTypes.join(', ')}`); + console.log('[PasteInput] Unsupported paste, types:', payload.availableTypes); + break; + } + } + + onDrop(args: PasteEventData) { + console.log('[PasteInput] Drop event'); + this.onPaste(args); + } +}