From 09b96da97ad67dfb7a09ad2dc30cda99085d23cf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 02:05:40 +0000 Subject: [PATCH 01/28] Add preview-with-fade, JPEG support, and white-overlay bake - New ImageProcessor native module decodes the source (PNG or JPEG), paints a white overlay with SRC_ATOP so transparency is preserved, and writes a PNG to the app cache. Used at insert time whenever fade > 0 or the source is JPEG (PluginNoteAPI.insertImage is PNG-only). - StubPackage now registers ImageProcessor instead of returning empty. - App.tsx: tapping a thumbnail opens a preview screen with the image, a live white-overlay opacity matching the requested fade, a custom PanResponder slider with +/- steppers and 0/25/50/75/90 % presets, and Cancel/Insert buttons. JPEG/JPG added to the accepted extensions. - Renamed the surfaced plugin from "Embed PNG" to "Embed Image" and bumped to 0.3.0 / versionCode 8. --- App.tsx | 227 +++++++++++++++++- PluginConfig.json | 8 +- README.md | 12 +- .../com/embedimage/ImageProcessorModule.kt | 65 +++++ .../main/java/com/embedimage/StubPackage.kt | 9 +- index.js | 2 +- 6 files changed, 297 insertions(+), 26 deletions(-) create mode 100644 android/app/src/main/java/com/embedimage/ImageProcessorModule.kt diff --git a/App.tsx b/App.tsx index 52dd540..cb89bb1 100644 --- a/App.tsx +++ b/App.tsx @@ -1,8 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, FlatList, Image, + NativeModules, + PanResponder, Pressable, SafeAreaView, StyleSheet, @@ -15,7 +17,7 @@ import { FileUtils, PluginManager, PluginNoteAPI } from 'sn-plugin-lib'; type SortKey = 'date_desc' | 'date_asc' | 'name'; type ImageItem = { name: string; path: string }; -const IMAGE_EXTS = ['.png']; +const IMAGE_EXTS = ['.png', '.jpg', '.jpeg']; const IMAGES_DIR = '/storage/emulated/0/Document/Images'; const SORT_OPTIONS: { key: SortKey; label: string }[] = [ @@ -24,6 +26,10 @@ const SORT_OPTIONS: { key: SortKey; label: string }[] = [ { key: 'name', label: 'Name' }, ]; +const { ImageProcessor } = NativeModules as { + ImageProcessor?: { processForEmbed: (inputPath: string, whiteAlpha: number) => Promise }; +}; + function basename(path: string): string { const i = path.lastIndexOf('/'); return i >= 0 ? path.slice(i + 1) : path; @@ -34,6 +40,10 @@ function isImage(name: string): boolean { return IMAGE_EXTS.some((ext) => lower.endsWith(ext)); } +function isPng(name: string): boolean { + return name.toLowerCase().endsWith('.png'); +} + function sortItems(items: ImageItem[], sort: SortKey): ImageItem[] { const copy = items.slice(); const byName = (a: ImageItem, b: ImageItem) => @@ -44,11 +54,51 @@ function sortItems(items: ImageItem[], sort: SortKey): ImageItem[] { return copy; } +function clamp(n: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, n)); +} + +function FadeSlider({ value, onChange }: { value: number; onChange: (v: number) => void }) { + const [width, setWidth] = useState(0); + const widthRef = useRef(width); + widthRef.current = width; + + const update = useCallback( + (x: number) => { + const w = Math.max(1, widthRef.current); + onChange(Math.round(clamp((x / w) * 100, 0, 100))); + }, + [onChange], + ); + + const responder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: () => true, + onPanResponderGrant: (e) => update(e.nativeEvent.locationX), + onPanResponderMove: (e) => update(e.nativeEvent.locationX), + }), + ).current; + + return ( + setWidth(e.nativeEvent.layout.width)} + {...responder.panHandlers} + > + + + + ); +} + export default function App(): React.JSX.Element { const [items, setItems] = useState([]); const [sort, setSort] = useState('date_desc'); const [status, setStatus] = useState('starting…'); const [busy, setBusy] = useState(false); + const [selected, setSelected] = useState(null); + const [fade, setFade] = useState(0); const load = useCallback(async () => { setStatus(`checking ${IMAGES_DIR}`); @@ -102,12 +152,38 @@ export default function App(): React.JSX.Element { const sorted = useMemo(() => sortItems(items, sort), [items, sort]); - const onPick = useCallback(async (item: ImageItem) => { - if (busy) return; + const openPreview = useCallback((item: ImageItem) => { + setSelected(item); + setFade(0); + }, []); + + const closePreview = useCallback(() => { + setSelected(null); + setFade(0); + }, []); + + const onInsert = useCallback(async () => { + if (!selected || busy) return; setBusy(true); - setStatus(`embedding ${item.name}…`); + setStatus(`embedding ${selected.name}…`); try { - const res: any = await PluginNoteAPI.insertImage(item.path); + const needsBake = fade > 0 || !isPng(selected.name); + let pathToInsert = selected.path; + + if (needsBake) { + if (!ImageProcessor?.processForEmbed) { + setStatus('native ImageProcessor missing — rebuild plugin'); + return; + } + try { + pathToInsert = await ImageProcessor.processForEmbed(selected.path, fade / 100); + } catch (e: any) { + setStatus(`process failed: ${e?.message ?? e}`); + return; + } + } + + const res: any = await PluginNoteAPI.insertImage(pathToInsert); if (!res || res.success === false) { const msg = res?.error?.message ?? 'insertImage failed'; setStatus(`insert failed: ${msg}`); @@ -115,7 +191,7 @@ export default function App(): React.JSX.Element { } try { await PluginNoteAPI.saveCurrentNote(); } catch {} try { - ToastAndroid.showWithGravity(`Embedded ${item.name}`, ToastAndroid.SHORT, ToastAndroid.BOTTOM); + ToastAndroid.showWithGravity(`Embedded ${selected.name}`, ToastAndroid.SHORT, ToastAndroid.BOTTOM); } catch {} PluginManager.closePluginView().catch(() => {}); } catch (err: any) { @@ -123,12 +199,85 @@ export default function App(): React.JSX.Element { } finally { setBusy(false); } - }, [busy]); + }, [selected, fade, busy]); + + if (selected) { + const overlayOpacity = fade / 100; + return ( + + + + Back + + {selected.name} + + + {status} + + + + + + + + Fade to white + {fade}% + + + + setFade((v) => clamp(v - 5, 0, 100))} disabled={busy}> + - + + + + + setFade((v) => clamp(v + 5, 0, 100))} disabled={busy}> + + + + + + + {[0, 25, 50, 75, 90].map((p) => ( + setFade(p)} + disabled={busy} + > + {p}% + + ))} + + + + + Cancel + + + Insert + + + + {busy ? ( + + + + ) : null} + + ); + } return ( - Embed PNG + Embed Image PluginManager.closePluginView().catch(() => {})}> Close @@ -158,7 +307,7 @@ export default function App(): React.JSX.Element { {sorted.length === 0 ? ( - No PNG files yet. + No images yet. {IMAGES_DIR} ) : ( @@ -168,7 +317,7 @@ export default function App(): React.JSX.Element { numColumns={3} contentContainerStyle={styles.grid} renderItem={({ item }) => ( - onPick(item)} disabled={busy}> + openPreview(item)} disabled={busy}> Vibe-coded with Claude Code. Read the diffs before trusting it on a device you care about. -Adds an **Embed PNG** button to the plugins area of the sidebar in the NOTE editor. Tapping it opens a browser of PNG files in `Document/Images` (auto-created if absent) with thumbnails, filenames, and a sort selector (Newest — default, Oldest, Name). Selecting a PNG inserts it into the current note on the current layer; reposition or resize it afterward with the lasso tool. +Adds an **Embed Image** button to the plugins area of the sidebar in the NOTE editor. Tapping it opens a browser of PNG/JPEG files in `Document/Images` (auto-created if absent) with thumbnails, filenames, and a sort selector (Newest — default, Oldest, Name). Tapping a thumbnail opens a preview with a fade-to-white slider; tap **Insert** to embed it into the current note on the current layer (reposition or resize afterward with the lasso tool). -**PNG only.** The Supernote host's image-insert API is built around PNG (the SDK parameter is named `pngPath`). JPEG/WebP files in the directory are ignored. Convert other formats to PNG before dropping them in. +**Fade slider.** 0 % = original image, 100 % = pure white. Useful for image references on e-ink — pick a high fade so your strokes stand out against a ghosted reference. The fade is baked into a temporary PNG before insert. + +**JPEG support.** PNGs are embedded directly when fade = 0. JPEGs (and any image with fade > 0) are decoded and re-encoded as PNG in the app cache before being handed to `PluginNoteAPI.insertImage`, which is PNG-only. ## Layout - `PluginConfig.json` — plugin manifest (id, name, icon, version) - `index.js` — registers the sidebar button via `PluginManager.registerButton(1, ['NOTE'], …)` -- `App.tsx` — full-screen picker UI (thumbnail grid + sort) +- `App.tsx` — full-screen picker UI (thumbnail grid + sort, preview with fade slider) - `assets/icon.png` — sidebar button icon (pre-rendered from the source SVG) -- `android/` — RN Android shell. An empty `StubPackage` is registered to force `buildCustomApkDebug` so the package includes an `app.npk` (required for the plugin's RN runtime to load on-device) +- `android/` — RN Android shell. `StubPackage` registers the `ImageProcessor` native module (Bitmap → white-overlay → PNG bake) and forces `buildCustomApkDebug` so the package includes an `app.npk` (required for the plugin's RN runtime to load on-device) - `buildPlugin.sh` — bundles JS + native APK + manifest into `build/outputs/embedimage.snplg` ## Build & install diff --git a/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt b/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt new file mode 100644 index 0000000..3bc0cfe --- /dev/null +++ b/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt @@ -0,0 +1,65 @@ +package com.embedimage + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import java.io.File +import java.io.FileOutputStream + +// Bakes a white-tint overlay into the source bitmap and writes a PNG to the +// app cache. Used to (a) fade an image toward white for reference tracing on +// e-ink and (b) convert JPEGs to PNG since PluginNoteAPI.insertImage is +// PNG-only. +class ImageProcessorModule(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String = "ImageProcessor" + + @ReactMethod + fun processForEmbed(inputPath: String, whiteAlpha: Double, promise: Promise) { + var src: Bitmap? = null + var out: Bitmap? = null + try { + val options = BitmapFactory.Options().apply { + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + src = BitmapFactory.decodeFile(inputPath, options) + ?: throw RuntimeException("decode failed: $inputPath") + + out = Bitmap.createBitmap(src.width, src.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(out) + canvas.drawBitmap(src, 0f, 0f, null) + + val clamped = whiteAlpha.coerceIn(0.0, 1.0) + if (clamped > 0.0) { + val alpha = (clamped * 255).toInt().coerceIn(0, 255) + // SRC_ATOP keeps the source alpha channel — transparent regions + // stay transparent instead of being filled with white. + val paint = Paint().apply { + color = Color.argb(alpha, 255, 255, 255) + xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP) + } + canvas.drawRect(0f, 0f, out.width.toFloat(), out.height.toFloat(), paint) + } + + val outFile = File(reactApplicationContext.cacheDir, "embed_${System.currentTimeMillis()}.png") + FileOutputStream(outFile).use { stream -> + out.compress(Bitmap.CompressFormat.PNG, 100, stream) + } + promise.resolve(outFile.absolutePath) + } catch (e: Throwable) { + promise.reject("E_PROCESS", e.message ?: e.toString(), e) + } finally { + src?.recycle() + out?.recycle() + } + } +} diff --git a/android/app/src/main/java/com/embedimage/StubPackage.kt b/android/app/src/main/java/com/embedimage/StubPackage.kt index ffd8d20..b504984 100644 --- a/android/app/src/main/java/com/embedimage/StubPackage.kt +++ b/android/app/src/main/java/com/embedimage/StubPackage.kt @@ -7,10 +7,11 @@ import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.uimanager.ReactShadowNode import com.facebook.react.uimanager.ViewManager -// Empty ReactPackage. Its only purpose is to make the build pipeline -// produce an app.npk so the plugin host loads the React Native runtime -// for this plugin. Embed Image uses only SDK modules at runtime. +// Registers native modules for the plugin. The package's existence also +// forces the build pipeline to produce an app.npk, which the plugin host +// requires to load the React Native runtime for this plugin. class StubPackage : ReactPackage { - override fun createNativeModules(reactContext: ReactApplicationContext): List = emptyList() + override fun createNativeModules(reactContext: ReactApplicationContext): List = + listOf(ImageProcessorModule(reactContext)) override fun createViewManagers(reactContext: ReactApplicationContext): List>> = emptyList() } diff --git a/index.js b/index.js index 32d1ebf..7216dbe 100644 --- a/index.js +++ b/index.js @@ -12,7 +12,7 @@ const BUTTON_ID = 1; // type=1: sidebar button (plugins area). showType=1: tap opens the plugin view (App.tsx). PluginManager.registerButton(1, ['NOTE'], { id: BUTTON_ID, - name: 'Embed PNG', + name: 'Embed Image', icon: Image.resolveAssetSource(require('./assets/icon.png')).uri, showType: 1, }); From 578268396facacfc7d9c99441da532e014540114 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 03:35:18 +0000 Subject: [PATCH 02/28] Add folder navigator, system picker (WebDAV), B/C/gamma adjustments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Folder navigator: replaces the fixed-folder grid. Entries are classified as image (.png/.jpg/.jpeg/.bmp/.gif/.webp) or folder (no extension); folders are tappable and sorted first. Up/Home buttons and a current-path bar. Navigation is clamped to /storage/emulated/0. - Browse… button calls RattaFileSelector.selectFile so users can pick an image from anywhere the host exposes, including WebDAV mounts and the SD card. - ImageProcessor.processForEmbed now accepts brightness (-100..100), contrast (-100..100), gamma (0.5..2.0), and a previewMaxDim that downsamples via BitmapFactory inSampleSize so live previews stay responsive on e-ink. Pixels are processed in a single pass: B/C linear, then a gamma LUT, then the white overlay (alpha-preserving). - New cleanupCache() native method clears prev_*.png/embed_*.png from app cacheDir on plugin open. - Preview screen now has four AdjustRow sliders (each with -/+ steppers and a Reset button) and a debounced auto-bake (~350 ms) that updates the Image source with the downsampled preview PNG. Final Insert bakes at full resolution. - Bumped to 0.4.0 / versionCode 9. --- App.tsx | 551 ++++++++++++++---- PluginConfig.json | 6 +- README.md | 17 +- .../com/embedimage/ImageProcessorModule.kt | 128 +++- 4 files changed, 541 insertions(+), 161 deletions(-) diff --git a/App.tsx b/App.tsx index cb89bb1..12ec0e8 100644 --- a/App.tsx +++ b/App.tsx @@ -7,18 +7,22 @@ import { PanResponder, Pressable, SafeAreaView, + ScrollView, StyleSheet, Text, ToastAndroid, View, } from 'react-native'; -import { FileUtils, PluginManager, PluginNoteAPI } from 'sn-plugin-lib'; +import { FileUtils, PluginManager, PluginNoteAPI, RattaFileSelector } from 'sn-plugin-lib'; type SortKey = 'date_desc' | 'date_asc' | 'name'; -type ImageItem = { name: string; path: string }; +type EntryKind = 'image' | 'folder'; +type Entry = { name: string; path: string; kind: EntryKind }; -const IMAGE_EXTS = ['.png', '.jpg', '.jpeg']; -const IMAGES_DIR = '/storage/emulated/0/Document/Images'; +const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.bmp', '.gif', '.webp']; +const ROOT = '/storage/emulated/0'; +const DEFAULT_DIR = ROOT + '/Document/Images'; +const PREVIEW_MAX_DIM = 800; const SORT_OPTIONS: { key: SortKey; label: string }[] = [ { key: 'date_desc', label: 'Newest' }, @@ -27,7 +31,17 @@ const SORT_OPTIONS: { key: SortKey; label: string }[] = [ ]; const { ImageProcessor } = NativeModules as { - ImageProcessor?: { processForEmbed: (inputPath: string, whiteAlpha: number) => Promise }; + ImageProcessor?: { + processForEmbed: ( + inputPath: string, + whiteAlpha: number, + brightness: number, + contrast: number, + gamma: number, + previewMaxDim: number, + ) => Promise; + cleanupCache: () => Promise; + }; }; function basename(path: string): string { @@ -35,30 +49,57 @@ function basename(path: string): string { return i >= 0 ? path.slice(i + 1) : path; } -function isImage(name: string): boolean { +function parentDir(p: string): string { + const trimmed = p.replace(/\/+$/, ''); + const i = trimmed.lastIndexOf('/'); + if (i <= 0) return '/'; + return trimmed.slice(0, i); +} + +function classifyEntry(name: string): EntryKind | null { const lower = name.toLowerCase(); - return IMAGE_EXTS.some((ext) => lower.endsWith(ext)); + if (IMAGE_EXTS.some((e) => lower.endsWith(e))) return 'image'; + // No extension → assume folder. Files with non-image extensions are skipped. + const dot = lower.lastIndexOf('.'); + if (dot <= 0) return 'folder'; + return null; } function isPng(name: string): boolean { return name.toLowerCase().endsWith('.png'); } -function sortItems(items: ImageItem[], sort: SortKey): ImageItem[] { - const copy = items.slice(); - const byName = (a: ImageItem, b: ImageItem) => +function sortEntries(items: Entry[], sort: SortKey): Entry[] { + const folders = items.filter((e) => e.kind === 'folder'); + const images = items.filter((e) => e.kind === 'image'); + const byName = (a: Entry, b: Entry) => a.name.localeCompare(b.name, undefined, { numeric: true }); - if (sort === 'name') copy.sort(byName); - else if (sort === 'date_asc') copy.sort(byName); - else copy.sort((a, b) => byName(b, a)); - return copy; + folders.sort(byName); + if (sort === 'name') images.sort(byName); + else if (sort === 'date_asc') images.sort(byName); + else images.sort((a, b) => byName(b, a)); + return folders.concat(images); } function clamp(n: number, lo: number, hi: number): number { return Math.max(lo, Math.min(hi, n)); } -function FadeSlider({ value, onChange }: { value: number; onChange: (v: number) => void }) { +function RangeSlider({ + value, + min, + max, + step, + onChange, + disabled, +}: { + value: number; + min: number; + max: number; + step: number; + onChange: (v: number) => void; + disabled?: boolean; +}) { const [width, setWidth] = useState(0); const widthRef = useRef(width); widthRef.current = width; @@ -66,108 +107,284 @@ function FadeSlider({ value, onChange }: { value: number; onChange: (v: number) const update = useCallback( (x: number) => { const w = Math.max(1, widthRef.current); - onChange(Math.round(clamp((x / w) * 100, 0, 100))); + const pct = clamp(x / w, 0, 1); + const raw = min + pct * (max - min); + const snapped = Math.round(raw / step) * step; + onChange(clamp(snapped, min, max)); }, - [onChange], + [min, max, step, onChange], ); const responder = useRef( PanResponder.create({ - onStartShouldSetPanResponder: () => true, - onMoveShouldSetPanResponder: () => true, + onStartShouldSetPanResponder: () => !disabled, + onMoveShouldSetPanResponder: () => !disabled, onPanResponderGrant: (e) => update(e.nativeEvent.locationX), onPanResponderMove: (e) => update(e.nativeEvent.locationX), }), ).current; + const pct = clamp((value - min) / (max - min), 0, 1) * 100; + return ( setWidth(e.nativeEvent.layout.width)} {...responder.panHandlers} > - - + + + ); } +function AdjustRow({ + label, + value, + display, + min, + max, + step, + onChange, + onReset, + disabled, +}: { + label: string; + value: number; + display: string; + min: number; + max: number; + step: number; + onChange: (v: number) => void; + onReset: () => void; + disabled?: boolean; +}) { + return ( + + + {label} + {display} + + Reset + + + + onChange(clamp(value - step, min, max))} + disabled={disabled} + > + - + + + + + onChange(clamp(value + step, min, max))} + disabled={disabled} + > + + + + + + ); +} + +const DEFAULT_GAMMA = 1.0; + +function adjustmentsAreDefault(fade: number, brightness: number, contrast: number, gamma: number) { + return ( + fade === 0 && + brightness === 0 && + contrast === 0 && + Math.abs(gamma - DEFAULT_GAMMA) < 1e-6 + ); +} + export default function App(): React.JSX.Element { - const [items, setItems] = useState([]); + const [currentDir, setCurrentDir] = useState(DEFAULT_DIR); + const [entries, setEntries] = useState([]); const [sort, setSort] = useState('date_desc'); const [status, setStatus] = useState('starting…'); const [busy, setBusy] = useState(false); - const [selected, setSelected] = useState(null); + const [selected, setSelected] = useState(null); const [fade, setFade] = useState(0); - - const load = useCallback(async () => { - setStatus(`checking ${IMAGES_DIR}`); + const [brightness, setBrightness] = useState(0); + const [contrast, setContrast] = useState(0); + const [gamma, setGamma] = useState(DEFAULT_GAMMA); + const [previewPath, setPreviewPath] = useState(null); + const [previewing, setPreviewing] = useState(false); + + const load = useCallback(async (dir: string) => { + setStatus(`listing ${dir}`); try { let exists = false; try { - exists = await FileUtils.exists(IMAGES_DIR); + exists = await FileUtils.exists(dir); } catch (e: any) { setStatus(`exists() threw: ${e?.message ?? e}`); return; } if (!exists) { - setStatus('directory missing — creating'); - try { - await FileUtils.makeDir(IMAGES_DIR); - } catch (e: any) { - setStatus(`makeDir() threw: ${e?.message ?? e}`); + if (dir === DEFAULT_DIR) { + setStatus('default directory missing — creating'); + try { + await FileUtils.makeDir(dir); + } catch (e: any) { + setStatus(`makeDir() threw: ${e?.message ?? e}`); + return; + } + } else { + setStatus(`directory missing: ${dir}`); + setEntries([]); return; } } - setStatus('listing files…'); - let entries: any = null; + let raw: any = null; try { - entries = await FileUtils.listFiles(IMAGES_DIR); + raw = await FileUtils.listFiles(dir); } catch (e: any) { setStatus(`listFiles() threw: ${e?.message ?? e}`); return; } - const list: any[] = Array.isArray(entries) ? entries : []; - const imgs: ImageItem[] = []; + const list: any[] = Array.isArray(raw) ? raw : []; + const out: Entry[] = []; for (const entry of list) { const path = typeof entry === 'string' ? entry : entry?.path; - const type = typeof entry === 'string' ? 1 : entry?.type; - if (!path || type === 0) continue; + const sdkType = typeof entry === 'string' ? undefined : entry?.type; + if (!path) continue; const name = basename(path); - if (!isImage(name)) continue; - imgs.push({ name, path }); + if (!name || name.startsWith('.')) continue; + let kind: EntryKind | null; + if (sdkType === 0) kind = 'folder'; + else if (sdkType === 1) kind = isPng(name) || classifyEntry(name) === 'image' ? 'image' : null; + else kind = classifyEntry(name); + if (!kind) continue; + out.push({ name, path, kind }); } - setItems(imgs); - setStatus(`found ${imgs.length} image${imgs.length === 1 ? '' : 's'} (raw entries: ${list.length})`); + setEntries(out); + const imgs = out.filter((e) => e.kind === 'image').length; + const dirs = out.filter((e) => e.kind === 'folder').length; + setStatus(`${imgs} image${imgs === 1 ? '' : 's'}, ${dirs} folder${dirs === 1 ? '' : 's'}`); } catch (err: any) { setStatus(`unexpected: ${err?.message ?? err}`); } }, []); useEffect(() => { - load(); - }, [load]); + load(currentDir); + }, [currentDir, load]); - const sorted = useMemo(() => sortItems(items, sort), [items, sort]); + // One-shot cache cleanup on plugin open. + useEffect(() => { + ImageProcessor?.cleanupCache?.().catch(() => {}); + }, []); - const openPreview = useCallback((item: ImageItem) => { - setSelected(item); + // Debounced preview bake. Reuses the source path when all sliders are at + // defaults and the source is already PNG. + useEffect(() => { + if (!selected) return; + const allDefault = adjustmentsAreDefault(fade, brightness, contrast, gamma); + if (allDefault && isPng(selected.name)) { + setPreviewPath(null); + return; + } + if (!ImageProcessor?.processForEmbed) return; + const t = setTimeout(() => { + let cancelled = false; + setPreviewing(true); + ImageProcessor.processForEmbed( + selected.path, + fade / 100, + brightness, + contrast, + gamma, + PREVIEW_MAX_DIM, + ) + .then((p) => { + if (!cancelled) setPreviewPath(p); + }) + .catch((e: any) => { + if (!cancelled) setStatus(`preview failed: ${e?.message ?? e}`); + }) + .finally(() => { + if (!cancelled) setPreviewing(false); + }); + return () => { + cancelled = true; + }; + }, 350); + return () => clearTimeout(t); + }, [selected, fade, brightness, contrast, gamma]); + + const sorted = useMemo(() => sortEntries(entries, sort), [entries, sort]); + + const openPreview = useCallback((entry: Entry) => { + setSelected(entry); setFade(0); + setBrightness(0); + setContrast(0); + setGamma(DEFAULT_GAMMA); + setPreviewPath(null); }, []); const closePreview = useCallback(() => { setSelected(null); - setFade(0); + setPreviewPath(null); + setPreviewing(false); }, []); + const onPickEntry = useCallback( + (entry: Entry) => { + if (entry.kind === 'folder') { + setCurrentDir(entry.path); + } else { + openPreview(entry); + } + }, + [openPreview], + ); + + const onSystemPicker = useCallback(async () => { + try { + setStatus('opening system file picker…'); + const res: any = await RattaFileSelector.selectFile({ + selectType: 1, + suffixList: ['png', 'jpg', 'jpeg', 'bmp', 'gif', 'webp'], + maxNum: 1, + title: 'Pick image', + }); + const path: string | undefined = Array.isArray(res) ? res[0] : res; + if (!path) { + setStatus('picker cancelled'); + return; + } + setStatus(`picked ${path}`); + const name = basename(path); + openPreview({ name, path, kind: 'image' }); + } catch (e: any) { + setStatus(`picker failed: ${e?.message ?? e}`); + } + }, [openPreview]); + const onInsert = useCallback(async () => { if (!selected || busy) return; setBusy(true); setStatus(`embedding ${selected.name}…`); try { - const needsBake = fade > 0 || !isPng(selected.name); + const allDefault = adjustmentsAreDefault(fade, brightness, contrast, gamma); + const needsBake = !allDefault || !isPng(selected.name); let pathToInsert = selected.path; if (needsBake) { @@ -176,7 +393,14 @@ export default function App(): React.JSX.Element { return; } try { - pathToInsert = await ImageProcessor.processForEmbed(selected.path, fade / 100); + pathToInsert = await ImageProcessor.processForEmbed( + selected.path, + fade / 100, + brightness, + contrast, + gamma, + 0, + ); } catch (e: any) { setStatus(`process failed: ${e?.message ?? e}`); return; @@ -189,9 +413,15 @@ export default function App(): React.JSX.Element { setStatus(`insert failed: ${msg}`); return; } - try { await PluginNoteAPI.saveCurrentNote(); } catch {} try { - ToastAndroid.showWithGravity(`Embedded ${selected.name}`, ToastAndroid.SHORT, ToastAndroid.BOTTOM); + await PluginNoteAPI.saveCurrentNote(); + } catch {} + try { + ToastAndroid.showWithGravity( + `Embedded ${selected.name}`, + ToastAndroid.SHORT, + ToastAndroid.BOTTOM, + ); } catch {} PluginManager.closePluginView().catch(() => {}); } catch (err: any) { @@ -199,68 +429,98 @@ export default function App(): React.JSX.Element { } finally { setBusy(false); } - }, [selected, fade, busy]); + }, [selected, fade, brightness, contrast, gamma, busy]); + + const navigateUp = useCallback(() => { + if (currentDir === ROOT) return; + const p = parentDir(currentDir); + if (!p.startsWith(ROOT)) { + setCurrentDir(ROOT); + } else { + setCurrentDir(p); + } + }, [currentDir]); + + const goHome = useCallback(() => setCurrentDir(DEFAULT_DIR), []); if (selected) { - const overlayOpacity = fade / 100; + const sourceUri = 'file://' + (previewPath ?? selected.path); return ( Back - {selected.name} + + {selected.name} + + {previewing ? : null} - {status} + + {status} + - - - - - - Fade to white - {fade}% + - - setFade((v) => clamp(v - 5, 0, 100))} disabled={busy}> - - - - - - - setFade((v) => clamp(v + 5, 0, 100))} disabled={busy}> - + - - - - - {[0, 25, 50, 75, 90].map((p) => ( - setFade(p)} - disabled={busy} - > - {p}% - - ))} - + + setFade(0)} + disabled={busy} + /> + 0 ? '+' : ''}${brightness}`} + min={-100} + max={100} + step={5} + onChange={setBrightness} + onReset={() => setBrightness(0)} + disabled={busy} + /> + 0 ? '+' : ''}${contrast}`} + min={-100} + max={100} + step={5} + onChange={setContrast} + onReset={() => setContrast(0)} + disabled={busy} + /> + setGamma(Math.round(v * 100) / 100)} + onReset={() => setGamma(DEFAULT_GAMMA)} + disabled={busy} + /> + - + Cancel - + Insert @@ -274,6 +534,7 @@ export default function App(): React.JSX.Element { ); } + const canGoUp = currentDir !== ROOT; return ( @@ -283,7 +544,21 @@ export default function App(): React.JSX.Element { - {status} + + {status} + + + + + Up + + + Home + + + {currentDir} + + Sort: @@ -300,15 +575,21 @@ export default function App(): React.JSX.Element { ); })} - + + Browse… + + load(currentDir)} style={styles.btn}> Refresh {sorted.length === 0 ? ( - No images yet. - {IMAGES_DIR} + Nothing here. + {currentDir} + + Tap “Browse…” to open the system picker — includes WebDAV mounts and SD card. + ) : ( ( - openPreview(item)} disabled={busy}> - - {item.name} + onPickEntry(item)} disabled={busy}> + {item.kind === 'folder' ? ( + + 📁 + + ) : ( + + )} + + {item.name} + )} /> @@ -351,6 +636,12 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, paddingVertical: 6, borderBottomWidth: 1, borderBottomColor: '#ddd', }, + pathBar: { + flexDirection: 'row', alignItems: 'center', gap: 8, + paddingHorizontal: 12, paddingVertical: 8, + borderBottomWidth: 1, borderBottomColor: '#ccc', + }, + pathTxt: { flex: 1, fontSize: 12, color: '#555' }, sortBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 8, gap: 8, @@ -363,14 +654,22 @@ const styles = StyleSheet.create({ chipTxtActive: { color: '#fff' }, btn: { paddingHorizontal: 14, paddingVertical: 8, borderWidth: 1, borderColor: '#000' }, btnTxt: { fontSize: 14, color: '#000' }, + btnTxtMuted: { color: '#999' }, btnTxtPrimary: { color: '#fff' }, grid: { padding: 8 }, tile: { flex: 1 / 3, padding: 6, alignItems: 'center' }, thumb: { width: '100%', aspectRatio: 1, backgroundColor: '#eee' }, + folderThumb: { + width: '100%', aspectRatio: 1, backgroundColor: '#f4f4f4', + borderWidth: 1, borderColor: '#000', + alignItems: 'center', justifyContent: 'center', + }, + folderIcon: { fontSize: 40 }, tileName: { marginTop: 4, fontSize: 12, color: '#000', textAlign: 'center' }, center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }, empty: { fontSize: 16, color: '#000', marginBottom: 4 }, - emptySub: { fontSize: 13, color: '#444', textAlign: 'center' }, + emptySub: { fontSize: 13, color: '#444', textAlign: 'center', marginBottom: 12 }, + emptyHint: { fontSize: 12, color: '#666', textAlign: 'center' }, overlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.4)', @@ -383,23 +682,29 @@ const styles = StyleSheet.create({ overflow: 'hidden', }, previewImg: { width: '100%', height: '100%' }, - previewOverlay: { - position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, - backgroundColor: '#fff', + adjustScroll: { maxHeight: 320 }, + adjustScrollContent: { paddingHorizontal: 12, paddingBottom: 4 }, + adjustRow: { paddingVertical: 6, borderTopWidth: 1, borderTopColor: '#eee' }, + adjustHeader: { + flexDirection: 'row', alignItems: 'center', gap: 8, + paddingHorizontal: 4, }, - controlBar: { - flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - paddingHorizontal: 16, paddingTop: 8, + adjustLabel: { flex: 1, fontSize: 14, color: '#000' }, + adjustValue: { fontSize: 14, color: '#000', fontVariant: ['tabular-nums'] }, + resetBtn: { + paddingHorizontal: 8, paddingVertical: 4, + borderWidth: 1, borderColor: '#000', }, - controlLabel: { fontSize: 14, color: '#000' }, - controlValue: { fontSize: 14, color: '#000', fontVariant: ['tabular-nums'] }, + resetBtnTxt: { fontSize: 12, color: '#000' }, sliderRow: { - flexDirection: 'row', alignItems: 'center', gap: 12, - paddingHorizontal: 16, paddingVertical: 8, + flexDirection: 'row', alignItems: 'center', gap: 10, + paddingHorizontal: 4, paddingVertical: 4, }, sliderWrap: { flex: 1 }, - sliderTrack: { - height: 36, justifyContent: 'center', + sliderTrack: { height: 36, justifyContent: 'center' }, + sliderRail: { + position: 'absolute', left: 0, right: 0, top: 16, height: 4, + backgroundColor: '#ddd', }, sliderFill: { position: 'absolute', left: 0, top: 16, height: 4, @@ -414,10 +719,6 @@ const styles = StyleSheet.create({ width: 40, height: 36, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: '#000', }, - presetRow: { - flexDirection: 'row', gap: 8, - paddingHorizontal: 16, paddingBottom: 8, - }, actionRow: { flexDirection: 'row', gap: 12, paddingHorizontal: 16, paddingVertical: 12, diff --git a/PluginConfig.json b/PluginConfig.json index 9b1cddc..5e69f2b 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -1,9 +1,9 @@ { "name": "Embed Image", - "desc": "Browse PNG/JPEG files in Document/Images, preview with adjustable fade, and embed into the current note.", + "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.3.0", - "versionCode": "8", + "versionName": "0.4.0", + "versionCode": "9", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/README.md b/README.md index 07c5c61..1b8a379 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,26 @@ > Vibe-coded with Claude Code. Read the diffs before trusting it on a device you care about. -Adds an **Embed Image** button to the plugins area of the sidebar in the NOTE editor. Tapping it opens a browser of PNG/JPEG files in `Document/Images` (auto-created if absent) with thumbnails, filenames, and a sort selector (Newest — default, Oldest, Name). Tapping a thumbnail opens a preview with a fade-to-white slider; tap **Insert** to embed it into the current note on the current layer (reposition or resize afterward with the lasso tool). +Adds an **Embed Image** button to the plugins area of the sidebar in the NOTE editor. Tapping it opens a file browser starting at `Document/Images` (auto-created if absent). Tap folders to navigate, **Up** / **Home** to jump around, or **Browse…** to open the device's native file picker — which includes mounted **WebDAV** servers and the **SD card**. Tap a thumbnail to open the preview, adjust, then **Insert** into the current note on the current layer (reposition or resize afterward with the lasso tool). -**Fade slider.** 0 % = original image, 100 % = pure white. Useful for image references on e-ink — pick a high fade so your strokes stand out against a ghosted reference. The fade is baked into a temporary PNG before insert. +**Supported formats.** PNG, JPEG, BMP, GIF (first frame), WEBP. PNGs with no adjustments are inserted as-is. Everything else is decoded and re-encoded to PNG in the app cache before being handed to `PluginNoteAPI.insertImage`, which is PNG-only. -**JPEG support.** PNGs are embedded directly when fade = 0. JPEGs (and any image with fade > 0) are decoded and re-encoded as PNG in the app cache before being handed to `PluginNoteAPI.insertImage`, which is PNG-only. +**Preview adjustments.** + +- **Fade to white** — 0 % = original, 100 % = pure white. For tracing references on e-ink: pick a high fade so your strokes stand out against a ghosted reference. +- **Brightness** — −100 .. +100, linear offset. +- **Contrast** — −100 .. +100, scaled around the midpoint. +- **Gamma** — 0.5 .. 2.0, per-channel LUT. + +The preview re-bakes (downsampled to 800 px) ~350 ms after the last slider change to keep e-ink refresh manageable. The full-resolution bake happens on **Insert**. ## Layout - `PluginConfig.json` — plugin manifest (id, name, icon, version) - `index.js` — registers the sidebar button via `PluginManager.registerButton(1, ['NOTE'], …)` -- `App.tsx` — full-screen picker UI (thumbnail grid + sort, preview with fade slider) +- `App.tsx` — full-screen picker UI (folder navigator + system-picker handoff, preview with fade / brightness / contrast / gamma sliders) - `assets/icon.png` — sidebar button icon (pre-rendered from the source SVG) -- `android/` — RN Android shell. `StubPackage` registers the `ImageProcessor` native module (Bitmap → white-overlay → PNG bake) and forces `buildCustomApkDebug` so the package includes an `app.npk` (required for the plugin's RN runtime to load on-device) +- `android/` — RN Android shell. `StubPackage` registers the `ImageProcessor` native module (decode → B/C/gamma LUT → white-overlay → PNG bake, with optional downsample for previews) and forces `buildCustomApkDebug` so the package includes an `app.npk` (required for the plugin's RN runtime to load on-device) - `buildPlugin.sh` — bundles JS + native APK + manifest into `build/outputs/embedimage.snplg` ## Build & install diff --git a/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt b/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt index 3bc0cfe..feee7d5 100644 --- a/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt +++ b/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt @@ -2,64 +2,136 @@ package com.embedimage import android.graphics.Bitmap import android.graphics.BitmapFactory -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.PorterDuff -import android.graphics.PorterDuffXfermode import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod import java.io.File import java.io.FileOutputStream +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow -// Bakes a white-tint overlay into the source bitmap and writes a PNG to the -// app cache. Used to (a) fade an image toward white for reference tracing on -// e-ink and (b) convert JPEGs to PNG since PluginNoteAPI.insertImage is -// PNG-only. +// Decodes the source (PNG/JPEG/BMP/GIF/WEBP), applies brightness + +// contrast (linear), gamma (per-channel LUT), and a final white-tint +// overlay that preserves alpha, then writes a PNG to the app cache. +// PluginNoteAPI.insertImage is PNG-only, so we always emit PNG. class ImageProcessorModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { override fun getName(): String = "ImageProcessor" @ReactMethod - fun processForEmbed(inputPath: String, whiteAlpha: Double, promise: Promise) { + fun processForEmbed( + inputPath: String, + whiteAlpha: Double, + brightness: Int, + contrast: Int, + gamma: Double, + previewMaxDim: Int, + promise: Promise, + ) { var src: Bitmap? = null - var out: Bitmap? = null try { - val options = BitmapFactory.Options().apply { + val decodeOpts = BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 } - src = BitmapFactory.decodeFile(inputPath, options) + if (previewMaxDim > 0) { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(inputPath, bounds) + val maxSide = max(bounds.outWidth, bounds.outHeight) + if (maxSide > 0) { + var sample = 1 + while (maxSide / (sample * 2) >= previewMaxDim) sample *= 2 + decodeOpts.inSampleSize = sample + } + } + src = BitmapFactory.decodeFile(inputPath, decodeOpts) ?: throw RuntimeException("decode failed: $inputPath") - out = Bitmap.createBitmap(src.width, src.height, Bitmap.Config.ARGB_8888) - val canvas = Canvas(out) - canvas.drawBitmap(src, 0f, 0f, null) - - val clamped = whiteAlpha.coerceIn(0.0, 1.0) - if (clamped > 0.0) { - val alpha = (clamped * 255).toInt().coerceIn(0, 255) - // SRC_ATOP keeps the source alpha channel — transparent regions - // stay transparent instead of being filled with white. - val paint = Paint().apply { - color = Color.argb(alpha, 255, 255, 255) - xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP) + val w = src.width + val h = src.height + val pixels = IntArray(w * h) + src.getPixels(pixels, 0, w, 0, 0, w, h) + + // Pre-compute gamma LUT. + val g = if (gamma <= 0.0) 1.0 else gamma + val invG = 1.0 / g + val gammaLut = IntArray(256) + for (i in 0..255) { + gammaLut[i] = (255.0 * (i / 255.0).pow(invG)).toInt().coerceIn(0, 255) + } + + // contrast: -100..100 → factor 0..2 around midpoint 128. + val contrastFactor = ((contrast.coerceIn(-100, 100) + 100).toDouble()) / 100.0 + val brightnessAdjust = brightness.coerceIn(-100, 100) + + val wa = (whiteAlpha.coerceIn(0.0, 1.0) * 255).toInt() + val invWa = 255 - wa + + for (i in pixels.indices) { + val p = pixels[i] + val a = (p ushr 24) and 0xFF + if (a == 0) continue + var r = (p ushr 16) and 0xFF + var gC = (p ushr 8) and 0xFF + var b = p and 0xFF + + r = applyBC(r, contrastFactor, brightnessAdjust) + gC = applyBC(gC, contrastFactor, brightnessAdjust) + b = applyBC(b, contrastFactor, brightnessAdjust) + + r = gammaLut[r] + gC = gammaLut[gC] + b = gammaLut[b] + + if (wa > 0) { + r = (r * invWa + 255 * wa) / 255 + gC = (gC * invWa + 255 * wa) / 255 + b = (b * invWa + 255 * wa) / 255 } - canvas.drawRect(0f, 0f, out.width.toFloat(), out.height.toFloat(), paint) + + pixels[i] = (a shl 24) or (r shl 16) or (gC shl 8) or b } - val outFile = File(reactApplicationContext.cacheDir, "embed_${System.currentTimeMillis()}.png") + val out = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + out.setPixels(pixels, 0, w, 0, 0, w, h) + + val tag = if (previewMaxDim > 0) "prev" else "embed" + val outFile = File(reactApplicationContext.cacheDir, "${tag}_${System.currentTimeMillis()}.png") FileOutputStream(outFile).use { stream -> out.compress(Bitmap.CompressFormat.PNG, 100, stream) } + out.recycle() promise.resolve(outFile.absolutePath) } catch (e: Throwable) { promise.reject("E_PROCESS", e.message ?: e.toString(), e) } finally { src?.recycle() - out?.recycle() + } + } + + private fun applyBC(channel: Int, contrastFactor: Double, brightnessAdjust: Int): Int { + val v = ((channel - 128) * contrastFactor + 128 + brightnessAdjust).toInt() + return min(255, max(0, v)) + } + + // Deletes prev_*.png / embed_*.png left over from previous sessions so + // the cache doesn't grow unbounded across launches. + @ReactMethod + fun cleanupCache(promise: Promise) { + try { + val dir = reactApplicationContext.cacheDir + var n = 0 + dir.listFiles()?.forEach { f -> + val name = f.name + if ((name.startsWith("prev_") || name.startsWith("embed_")) && name.endsWith(".png")) { + if (f.delete()) n++ + } + } + promise.resolve(n) + } catch (e: Throwable) { + promise.reject("E_CLEANUP", e.message ?: e.toString(), e) } } } From d84a7915ba564012f7d96261e8bf616184c74539 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 06:42:59 +0000 Subject: [PATCH 03/28] Add thumbnail size +/- and clarify WebDAV refresh limitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Size −/+ buttons in the sort bar adjust grid column count (2..6, default 3). FlatList is re-keyed on column change. - Tile sizing switched from hardcoded flex: 1/3 to a dynamic flexBasis/maxWidth = 100/columns so the grid recomputes per click. - Empty-state hint now spells out that re-tapping Browse re-launches the native picker, which is the only way to force a WebDAV listing refresh (the picker activity is owned by the host, so we can't add a refresh button inside it). - Bumped to 0.4.1 / versionCode 10. --- App.tsx | 34 ++++++++++++++++++++++++++++++---- PluginConfig.json | 4 ++-- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/App.tsx b/App.tsx index 12ec0e8..6bd7848 100644 --- a/App.tsx +++ b/App.tsx @@ -23,6 +23,9 @@ const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.bmp', '.gif', '.webp']; const ROOT = '/storage/emulated/0'; const DEFAULT_DIR = ROOT + '/Document/Images'; const PREVIEW_MAX_DIM = 800; +const MIN_COLUMNS = 2; +const MAX_COLUMNS = 6; +const DEFAULT_COLUMNS = 3; const SORT_OPTIONS: { key: SortKey; label: string }[] = [ { key: 'date_desc', label: 'Newest' }, @@ -214,6 +217,7 @@ export default function App(): React.JSX.Element { const [currentDir, setCurrentDir] = useState(DEFAULT_DIR); const [entries, setEntries] = useState([]); const [sort, setSort] = useState('date_desc'); + const [columns, setColumns] = useState(DEFAULT_COLUMNS); const [status, setStatus] = useState('starting…'); const [busy, setBusy] = useState(false); const [selected, setSelected] = useState(null); @@ -575,6 +579,21 @@ export default function App(): React.JSX.Element { ); })} + Size: + setColumns((c) => clamp(c + 1, MIN_COLUMNS, MAX_COLUMNS))} + style={styles.btn} + disabled={columns >= MAX_COLUMNS} + > + = MAX_COLUMNS && styles.btnTxtMuted]}>− + + setColumns((c) => clamp(c - 1, MIN_COLUMNS, MAX_COLUMNS))} + style={styles.btn} + disabled={columns <= MIN_COLUMNS} + > + + + Browse… @@ -588,17 +607,24 @@ export default function App(): React.JSX.Element { Nothing here. {currentDir} - Tap “Browse…” to open the system picker — includes WebDAV mounts and SD card. + Tap “Browse…” to open the system picker — includes WebDAV mounts and SD card. If the + WebDAV listing looks stale, cancel and tap Browse again; each tap re-launches the + picker activity and re-fetches the remote listing. ) : ( it.path} - numColumns={3} + numColumns={columns} contentContainerStyle={styles.grid} renderItem={({ item }) => ( - onPickEntry(item)} disabled={busy}> + onPickEntry(item)} + disabled={busy} + > {item.kind === 'folder' ? ( 📁 @@ -657,7 +683,7 @@ const styles = StyleSheet.create({ btnTxtMuted: { color: '#999' }, btnTxtPrimary: { color: '#fff' }, grid: { padding: 8 }, - tile: { flex: 1 / 3, padding: 6, alignItems: 'center' }, + tile: { padding: 6, alignItems: 'center' }, thumb: { width: '100%', aspectRatio: 1, backgroundColor: '#eee' }, folderThumb: { width: '100%', aspectRatio: 1, backgroundColor: '#f4f4f4', diff --git a/PluginConfig.json b/PluginConfig.json index 5e69f2b..df27ceb 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.4.0", - "versionCode": "9", + "versionName": "0.4.1", + "versionCode": "10", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" From a8511dd9b17f2d73a6bffc4abf171bf04e247118 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 06:53:20 +0000 Subject: [PATCH 04/28] Expose SD card / external storage as additional roots Previously the navigator clamped to /storage/emulated/0, so the in-plugin folder browser couldn't reach the SD card mount. Now: - On open, call FileUtils.getExternalDirPath() and store any returned mounts (filtered to exclude the internal path). - Path bar gains an "Internal" chip and one "SD" chip per external mount (labelled "SD 1", "SD 2" when there are multiples). The chip for the current root is highlighted. - Navigation clamp is now per-root: Up disables when currentDir equals whichever root contains it, and won't escape into the parent /storage/. - Bumped to 0.4.2 / versionCode 11. --- App.tsx | 66 +++++++++++++++++++++++++++++++++++++++++------ PluginConfig.json | 4 +-- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/App.tsx b/App.tsx index 6bd7848..42c28c1 100644 --- a/App.tsx +++ b/App.tsx @@ -20,8 +20,8 @@ type EntryKind = 'image' | 'folder'; type Entry = { name: string; path: string; kind: EntryKind }; const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.bmp', '.gif', '.webp']; -const ROOT = '/storage/emulated/0'; -const DEFAULT_DIR = ROOT + '/Document/Images'; +const INTERNAL_ROOT = '/storage/emulated/0'; +const DEFAULT_DIR = INTERNAL_ROOT + '/Document/Images'; const PREVIEW_MAX_DIM = 800; const MIN_COLUMNS = 2; const MAX_COLUMNS = 6; @@ -218,6 +218,7 @@ export default function App(): React.JSX.Element { const [entries, setEntries] = useState([]); const [sort, setSort] = useState('date_desc'); const [columns, setColumns] = useState(DEFAULT_COLUMNS); + const [sdRoots, setSdRoots] = useState([]); const [status, setStatus] = useState('starting…'); const [busy, setBusy] = useState(false); const [selected, setSelected] = useState(null); @@ -295,6 +296,33 @@ export default function App(): React.JSX.Element { ImageProcessor?.cleanupCache?.().catch(() => {}); }, []); + // Probe for SD card / external storage mounts once on open. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res: any = await (FileUtils as any).getExternalDirPath?.(); + if (cancelled) return; + const list: string[] = Array.isArray(res) + ? res.filter((p) => typeof p === 'string' && p && p !== INTERNAL_ROOT) + : []; + setSdRoots(list); + } catch { + // device may not expose any external storage; ignore. + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const allRoots = useMemo(() => [INTERNAL_ROOT, ...sdRoots], [sdRoots]); + const currentRoot = useMemo(() => { + return ( + allRoots.find((r) => currentDir === r || currentDir.startsWith(r + '/')) ?? INTERNAL_ROOT + ); + }, [allRoots, currentDir]); + // Debounced preview bake. Reuses the source path when all sliders are at // defaults and the source is already PNG. useEffect(() => { @@ -436,14 +464,14 @@ export default function App(): React.JSX.Element { }, [selected, fade, brightness, contrast, gamma, busy]); const navigateUp = useCallback(() => { - if (currentDir === ROOT) return; + if (currentDir === currentRoot) return; const p = parentDir(currentDir); - if (!p.startsWith(ROOT)) { - setCurrentDir(ROOT); - } else { + if (p === currentRoot || p.startsWith(currentRoot + '/')) { setCurrentDir(p); + } else { + setCurrentDir(currentRoot); } - }, [currentDir]); + }, [currentDir, currentRoot]); const goHome = useCallback(() => setCurrentDir(DEFAULT_DIR), []); @@ -538,7 +566,7 @@ export default function App(): React.JSX.Element { ); } - const canGoUp = currentDir !== ROOT; + const canGoUp = currentDir !== currentRoot; return ( @@ -559,6 +587,27 @@ export default function App(): React.JSX.Element { Home + setCurrentDir(INTERNAL_ROOT)} + > + + Internal + + + {sdRoots.map((r, i) => { + const active = currentRoot === r; + const label = sdRoots.length > 1 ? `SD ${i + 1}` : 'SD'; + return ( + setCurrentDir(r)} + > + {label} + + ); + })} {currentDir} @@ -679,6 +728,7 @@ const styles = StyleSheet.create({ chipTxt: { fontSize: 14, color: '#000' }, chipTxtActive: { color: '#fff' }, btn: { paddingHorizontal: 14, paddingVertical: 8, borderWidth: 1, borderColor: '#000' }, + btnActive: { backgroundColor: '#000' }, btnTxt: { fontSize: 14, color: '#000' }, btnTxtMuted: { color: '#999' }, btnTxtPrimary: { color: '#fff' }, diff --git a/PluginConfig.json b/PluginConfig.json index df27ceb..d0796da 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.4.1", - "versionCode": "10", + "versionName": "0.4.2", + "versionCode": "11", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" From f22a3b670a52ada6a22bfd0fc70dfd4e7a1fbfa0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 07:35:45 +0000 Subject: [PATCH 05/28] Add live screen-capture streaming from a Mac, Settings, adjustment-tab UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin side ----------- - New macapp/ Python project: Flask HTTP capture server backed by mss (full screen / region / Quartz-enumerated window) plus a Tkinter GUI with LAN-IP/port display, source picker, region-drag overlay, interval slider, start/pause toggle, and a rolling log. - New CaptureScreen: polls /frame at a configurable interval, decodes + bakes via the native module (downloadAndProcess), shows the latest preview, exposes adjustment sliders, an on-screen log (last 12 lines), and Insert + Replace-in-place buttons. - New SettingsScreen: edits and persists host / port / intervalSec via a SharedPreferences-backed key/value pair in the native module. - Browser screen gains Live… and Settings buttons in the header. Element tracking ---------------- - embedTracker uses PluginFileAPI.getLastElement after insertImage to capture the new Picture element's uuid / numInPage / rect, then on Replace re-fetches the element by uuid (so user-applied lasso moves are picked up), deletes it, and re-inserts a Picture element at the same rect/layer via insertElements. Adjustment UI refactor ---------------------- - Old: all four sliders stacked in a scroll view. - New: AdjustmentPanel with tabs (Fade / Brightness / Contrast / Gamma) showing one slider at a time plus ± steppers, Reset, and per-tab preset chips (e.g. 0/25/50/75/100 for Fade). Native module ------------- - downloadAndProcess(url, ...adjustments, previewMaxDim, timeoutMs): HttpURLConnection fetch → cache file → existing bake pipeline → PNG path. Used by the capture loop so streaming is one native round-trip per frame instead of fetch-in-JS + bake. - getConfigValue / setConfigValue: SharedPreferences-backed string KV; Settings serializes its config as JSON under the streamConfig key. - cleanupCache now also clears dl_*.bin temporary downloads. Misc ---- - AndroidManifest gains usesCleartextTraffic=true so the plugin can HTTP-fetch the Mac on the LAN. - App.tsx slimmed down to a router; screens live under src/screens/. - Bumped to 0.5.0 / versionCode 12. Note: Replace-in-place depends on PluginFileAPI.insertElements accepting a synthesised Picture element with type=200 + picture.rect/picturePath. If the host rejects that shape we'll see a "replace failed" log and the Insert path keeps working as a static one-shot. --- App.tsx | 829 +----------------- PluginConfig.json | 4 +- README.md | 9 +- android/app/src/main/AndroidManifest.xml | 1 + .../com/embedimage/ImageProcessorModule.kt | 98 ++- macapp/README.md | 59 ++ macapp/__init__.py | 0 macapp/__main__.py | 17 + macapp/gui.py | 259 ++++++ macapp/requirements.txt | 4 + macapp/run.sh | 21 + macapp/server.py | 275 ++++++ src/AdjustmentPanel.tsx | 198 +++++ src/RangeSlider.tsx | 77 ++ src/embedTracker.ts | 124 +++ src/imageProcessor.ts | 72 ++ src/screens/Browser.tsx | 410 +++++++++ src/screens/Capture.tsx | 368 ++++++++ src/screens/Preview.tsx | 177 ++++ src/screens/Settings.tsx | 192 ++++ src/storage.ts | 32 + src/types.ts | 41 + 22 files changed, 2465 insertions(+), 802 deletions(-) create mode 100644 macapp/README.md create mode 100644 macapp/__init__.py create mode 100644 macapp/__main__.py create mode 100644 macapp/gui.py create mode 100644 macapp/requirements.txt create mode 100755 macapp/run.sh create mode 100644 macapp/server.py create mode 100644 src/AdjustmentPanel.tsx create mode 100644 src/RangeSlider.tsx create mode 100644 src/embedTracker.ts create mode 100644 src/imageProcessor.ts create mode 100644 src/screens/Browser.tsx create mode 100644 src/screens/Capture.tsx create mode 100644 src/screens/Preview.tsx create mode 100644 src/screens/Settings.tsx create mode 100644 src/storage.ts create mode 100644 src/types.ts diff --git a/App.tsx b/App.tsx index 42c28c1..28b2084 100644 --- a/App.tsx +++ b/App.tsx @@ -1,809 +1,66 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - ActivityIndicator, - FlatList, - Image, - NativeModules, - PanResponder, - Pressable, - SafeAreaView, - ScrollView, - StyleSheet, - Text, - ToastAndroid, - View, -} from 'react-native'; -import { FileUtils, PluginManager, PluginNoteAPI, RattaFileSelector } from 'sn-plugin-lib'; - -type SortKey = 'date_desc' | 'date_asc' | 'name'; -type EntryKind = 'image' | 'folder'; -type Entry = { name: string; path: string; kind: EntryKind }; - -const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.bmp', '.gif', '.webp']; -const INTERNAL_ROOT = '/storage/emulated/0'; -const DEFAULT_DIR = INTERNAL_ROOT + '/Document/Images'; -const PREVIEW_MAX_DIM = 800; -const MIN_COLUMNS = 2; -const MAX_COLUMNS = 6; -const DEFAULT_COLUMNS = 3; - -const SORT_OPTIONS: { key: SortKey; label: string }[] = [ - { key: 'date_desc', label: 'Newest' }, - { key: 'date_asc', label: 'Oldest' }, - { key: 'name', label: 'Name' }, -]; - -const { ImageProcessor } = NativeModules as { - ImageProcessor?: { - processForEmbed: ( - inputPath: string, - whiteAlpha: number, - brightness: number, - contrast: number, - gamma: number, - previewMaxDim: number, - ) => Promise; - cleanupCache: () => Promise; - }; -}; - -function basename(path: string): string { - const i = path.lastIndexOf('/'); - return i >= 0 ? path.slice(i + 1) : path; -} - -function parentDir(p: string): string { - const trimmed = p.replace(/\/+$/, ''); - const i = trimmed.lastIndexOf('/'); - if (i <= 0) return '/'; - return trimmed.slice(0, i); -} - -function classifyEntry(name: string): EntryKind | null { - const lower = name.toLowerCase(); - if (IMAGE_EXTS.some((e) => lower.endsWith(e))) return 'image'; - // No extension → assume folder. Files with non-image extensions are skipped. - const dot = lower.lastIndexOf('.'); - if (dot <= 0) return 'folder'; - return null; -} - -function isPng(name: string): boolean { - return name.toLowerCase().endsWith('.png'); -} - -function sortEntries(items: Entry[], sort: SortKey): Entry[] { - const folders = items.filter((e) => e.kind === 'folder'); - const images = items.filter((e) => e.kind === 'image'); - const byName = (a: Entry, b: Entry) => - a.name.localeCompare(b.name, undefined, { numeric: true }); - folders.sort(byName); - if (sort === 'name') images.sort(byName); - else if (sort === 'date_asc') images.sort(byName); - else images.sort((a, b) => byName(b, a)); - return folders.concat(images); -} - -function clamp(n: number, lo: number, hi: number): number { - return Math.max(lo, Math.min(hi, n)); -} - -function RangeSlider({ - value, - min, - max, - step, - onChange, - disabled, -}: { - value: number; - min: number; - max: number; - step: number; - onChange: (v: number) => void; - disabled?: boolean; -}) { - const [width, setWidth] = useState(0); - const widthRef = useRef(width); - widthRef.current = width; - - const update = useCallback( - (x: number) => { - const w = Math.max(1, widthRef.current); - const pct = clamp(x / w, 0, 1); - const raw = min + pct * (max - min); - const snapped = Math.round(raw / step) * step; - onChange(clamp(snapped, min, max)); - }, - [min, max, step, onChange], - ); - - const responder = useRef( - PanResponder.create({ - onStartShouldSetPanResponder: () => !disabled, - onMoveShouldSetPanResponder: () => !disabled, - onPanResponderGrant: (e) => update(e.nativeEvent.locationX), - onPanResponderMove: (e) => update(e.nativeEvent.locationX), - }), - ).current; - - const pct = clamp((value - min) / (max - min), 0, 1) * 100; - - return ( - setWidth(e.nativeEvent.layout.width)} - {...responder.panHandlers} - > - - - - - ); -} - -function AdjustRow({ - label, - value, - display, - min, - max, - step, - onChange, - onReset, - disabled, -}: { - label: string; - value: number; - display: string; - min: number; - max: number; - step: number; - onChange: (v: number) => void; - onReset: () => void; - disabled?: boolean; -}) { - return ( - - - {label} - {display} - - Reset - - - - onChange(clamp(value - step, min, max))} - disabled={disabled} - > - - - - - - - onChange(clamp(value + step, min, max))} - disabled={disabled} - > - + - - - - ); -} - -const DEFAULT_GAMMA = 1.0; - -function adjustmentsAreDefault(fade: number, brightness: number, contrast: number, gamma: number) { - return ( - fade === 0 && - brightness === 0 && - contrast === 0 && - Math.abs(gamma - DEFAULT_GAMMA) < 1e-6 - ); -} +import React, { useCallback, useEffect, useState } from 'react'; +import { ImageProcessor } from './src/imageProcessor'; +import { BrowserScreen } from './src/screens/Browser'; +import { CaptureScreen } from './src/screens/Capture'; +import { PreviewScreen } from './src/screens/Preview'; +import { SettingsScreen } from './src/screens/Settings'; +import { loadStreamConfig } from './src/storage'; +import { DEFAULT_STREAM_CONFIG, Entry, Screen, StreamConfig } from './src/types'; export default function App(): React.JSX.Element { - const [currentDir, setCurrentDir] = useState(DEFAULT_DIR); - const [entries, setEntries] = useState([]); - const [sort, setSort] = useState('date_desc'); - const [columns, setColumns] = useState(DEFAULT_COLUMNS); - const [sdRoots, setSdRoots] = useState([]); - const [status, setStatus] = useState('starting…'); - const [busy, setBusy] = useState(false); - const [selected, setSelected] = useState(null); - const [fade, setFade] = useState(0); - const [brightness, setBrightness] = useState(0); - const [contrast, setContrast] = useState(0); - const [gamma, setGamma] = useState(DEFAULT_GAMMA); - const [previewPath, setPreviewPath] = useState(null); - const [previewing, setPreviewing] = useState(false); - - const load = useCallback(async (dir: string) => { - setStatus(`listing ${dir}`); - try { - let exists = false; - try { - exists = await FileUtils.exists(dir); - } catch (e: any) { - setStatus(`exists() threw: ${e?.message ?? e}`); - return; - } - if (!exists) { - if (dir === DEFAULT_DIR) { - setStatus('default directory missing — creating'); - try { - await FileUtils.makeDir(dir); - } catch (e: any) { - setStatus(`makeDir() threw: ${e?.message ?? e}`); - return; - } - } else { - setStatus(`directory missing: ${dir}`); - setEntries([]); - return; - } - } + const [screen, setScreen] = useState('browser'); + const [selectedEntry, setSelectedEntry] = useState(null); + const [streamConfig, setStreamConfig] = useState(DEFAULT_STREAM_CONFIG); - let raw: any = null; - try { - raw = await FileUtils.listFiles(dir); - } catch (e: any) { - setStatus(`listFiles() threw: ${e?.message ?? e}`); - return; - } - - const list: any[] = Array.isArray(raw) ? raw : []; - const out: Entry[] = []; - for (const entry of list) { - const path = typeof entry === 'string' ? entry : entry?.path; - const sdkType = typeof entry === 'string' ? undefined : entry?.type; - if (!path) continue; - const name = basename(path); - if (!name || name.startsWith('.')) continue; - let kind: EntryKind | null; - if (sdkType === 0) kind = 'folder'; - else if (sdkType === 1) kind = isPng(name) || classifyEntry(name) === 'image' ? 'image' : null; - else kind = classifyEntry(name); - if (!kind) continue; - out.push({ name, path, kind }); - } - setEntries(out); - const imgs = out.filter((e) => e.kind === 'image').length; - const dirs = out.filter((e) => e.kind === 'folder').length; - setStatus(`${imgs} image${imgs === 1 ? '' : 's'}, ${dirs} folder${dirs === 1 ? '' : 's'}`); - } catch (err: any) { - setStatus(`unexpected: ${err?.message ?? err}`); - } - }, []); - - useEffect(() => { - load(currentDir); - }, [currentDir, load]); - - // One-shot cache cleanup on plugin open. useEffect(() => { ImageProcessor?.cleanupCache?.().catch(() => {}); - }, []); - - // Probe for SD card / external storage mounts once on open. - useEffect(() => { - let cancelled = false; (async () => { - try { - const res: any = await (FileUtils as any).getExternalDirPath?.(); - if (cancelled) return; - const list: string[] = Array.isArray(res) - ? res.filter((p) => typeof p === 'string' && p && p !== INTERNAL_ROOT) - : []; - setSdRoots(list); - } catch { - // device may not expose any external storage; ignore. - } + const cfg = await loadStreamConfig(); + setStreamConfig(cfg); })(); - return () => { - cancelled = true; - }; }, []); - const allRoots = useMemo(() => [INTERNAL_ROOT, ...sdRoots], [sdRoots]); - const currentRoot = useMemo(() => { - return ( - allRoots.find((r) => currentDir === r || currentDir.startsWith(r + '/')) ?? INTERNAL_ROOT - ); - }, [allRoots, currentDir]); - - // Debounced preview bake. Reuses the source path when all sliders are at - // defaults and the source is already PNG. - useEffect(() => { - if (!selected) return; - const allDefault = adjustmentsAreDefault(fade, brightness, contrast, gamma); - if (allDefault && isPng(selected.name)) { - setPreviewPath(null); - return; - } - if (!ImageProcessor?.processForEmbed) return; - const t = setTimeout(() => { - let cancelled = false; - setPreviewing(true); - ImageProcessor.processForEmbed( - selected.path, - fade / 100, - brightness, - contrast, - gamma, - PREVIEW_MAX_DIM, - ) - .then((p) => { - if (!cancelled) setPreviewPath(p); - }) - .catch((e: any) => { - if (!cancelled) setStatus(`preview failed: ${e?.message ?? e}`); - }) - .finally(() => { - if (!cancelled) setPreviewing(false); - }); - return () => { - cancelled = true; - }; - }, 350); - return () => clearTimeout(t); - }, [selected, fade, brightness, contrast, gamma]); - - const sorted = useMemo(() => sortEntries(entries, sort), [entries, sort]); - const openPreview = useCallback((entry: Entry) => { - setSelected(entry); - setFade(0); - setBrightness(0); - setContrast(0); - setGamma(DEFAULT_GAMMA); - setPreviewPath(null); + setSelectedEntry(entry); + setScreen('preview'); }, []); - const closePreview = useCallback(() => { - setSelected(null); - setPreviewPath(null); - setPreviewing(false); + const goBrowser = useCallback(() => { + setSelectedEntry(null); + setScreen('browser'); }, []); - const onPickEntry = useCallback( - (entry: Entry) => { - if (entry.kind === 'folder') { - setCurrentDir(entry.path); - } else { - openPreview(entry); - } - }, - [openPreview], - ); - - const onSystemPicker = useCallback(async () => { - try { - setStatus('opening system file picker…'); - const res: any = await RattaFileSelector.selectFile({ - selectType: 1, - suffixList: ['png', 'jpg', 'jpeg', 'bmp', 'gif', 'webp'], - maxNum: 1, - title: 'Pick image', - }); - const path: string | undefined = Array.isArray(res) ? res[0] : res; - if (!path) { - setStatus('picker cancelled'); - return; - } - setStatus(`picked ${path}`); - const name = basename(path); - openPreview({ name, path, kind: 'image' }); - } catch (e: any) { - setStatus(`picker failed: ${e?.message ?? e}`); - } - }, [openPreview]); - - const onInsert = useCallback(async () => { - if (!selected || busy) return; - setBusy(true); - setStatus(`embedding ${selected.name}…`); - try { - const allDefault = adjustmentsAreDefault(fade, brightness, contrast, gamma); - const needsBake = !allDefault || !isPng(selected.name); - let pathToInsert = selected.path; - - if (needsBake) { - if (!ImageProcessor?.processForEmbed) { - setStatus('native ImageProcessor missing — rebuild plugin'); - return; - } - try { - pathToInsert = await ImageProcessor.processForEmbed( - selected.path, - fade / 100, - brightness, - contrast, - gamma, - 0, - ); - } catch (e: any) { - setStatus(`process failed: ${e?.message ?? e}`); - return; - } - } - - const res: any = await PluginNoteAPI.insertImage(pathToInsert); - if (!res || res.success === false) { - const msg = res?.error?.message ?? 'insertImage failed'; - setStatus(`insert failed: ${msg}`); - return; - } - try { - await PluginNoteAPI.saveCurrentNote(); - } catch {} - try { - ToastAndroid.showWithGravity( - `Embedded ${selected.name}`, - ToastAndroid.SHORT, - ToastAndroid.BOTTOM, - ); - } catch {} - PluginManager.closePluginView().catch(() => {}); - } catch (err: any) { - setStatus(`insert threw: ${err?.message ?? err}`); - } finally { - setBusy(false); - } - }, [selected, fade, brightness, contrast, gamma, busy]); - - const navigateUp = useCallback(() => { - if (currentDir === currentRoot) return; - const p = parentDir(currentDir); - if (p === currentRoot || p.startsWith(currentRoot + '/')) { - setCurrentDir(p); - } else { - setCurrentDir(currentRoot); - } - }, [currentDir, currentRoot]); - - const goHome = useCallback(() => setCurrentDir(DEFAULT_DIR), []); + if (screen === 'preview' && selectedEntry) { + return ; + } - if (selected) { - const sourceUri = 'file://' + (previewPath ?? selected.path); + if (screen === 'settings') { return ( - - - - Back - - - {selected.name} - - {previewing ? : null} - - - - {status} - - - - - - - - setFade(0)} - disabled={busy} - /> - 0 ? '+' : ''}${brightness}`} - min={-100} - max={100} - step={5} - onChange={setBrightness} - onReset={() => setBrightness(0)} - disabled={busy} - /> - 0 ? '+' : ''}${contrast}`} - min={-100} - max={100} - step={5} - onChange={setContrast} - onReset={() => setContrast(0)} - disabled={busy} - /> - setGamma(Math.round(v * 100) / 100)} - onReset={() => setGamma(DEFAULT_GAMMA)} - disabled={busy} - /> - - - - - Cancel - - - Insert - - + setStreamConfig(cfg)} + /> + ); + } - {busy ? ( - - - - ) : null} - + if (screen === 'capture') { + return ( + setScreen('settings')} + /> ); } - const canGoUp = currentDir !== currentRoot; return ( - - - Embed Image - PluginManager.closePluginView().catch(() => {})}> - Close - - - - - {status} - - - - - Up - - - Home - - setCurrentDir(INTERNAL_ROOT)} - > - - Internal - - - {sdRoots.map((r, i) => { - const active = currentRoot === r; - const label = sdRoots.length > 1 ? `SD ${i + 1}` : 'SD'; - return ( - setCurrentDir(r)} - > - {label} - - ); - })} - - {currentDir} - - - - - Sort: - {SORT_OPTIONS.map((opt) => { - const active = opt.key === sort; - return ( - setSort(opt.key)} - style={[styles.chip, active && styles.chipActive]} - > - {opt.label} - - ); - })} - - Size: - setColumns((c) => clamp(c + 1, MIN_COLUMNS, MAX_COLUMNS))} - style={styles.btn} - disabled={columns >= MAX_COLUMNS} - > - = MAX_COLUMNS && styles.btnTxtMuted]}>− - - setColumns((c) => clamp(c - 1, MIN_COLUMNS, MAX_COLUMNS))} - style={styles.btn} - disabled={columns <= MIN_COLUMNS} - > - + - - - Browse… - - load(currentDir)} style={styles.btn}> - Refresh - - - - {sorted.length === 0 ? ( - - Nothing here. - {currentDir} - - Tap “Browse…” to open the system picker — includes WebDAV mounts and SD card. If the - WebDAV listing looks stale, cancel and tap Browse again; each tap re-launches the - picker activity and re-fetches the remote listing. - - - ) : ( - it.path} - numColumns={columns} - contentContainerStyle={styles.grid} - renderItem={({ item }) => ( - onPickEntry(item)} - disabled={busy} - > - {item.kind === 'folder' ? ( - - 📁 - - ) : ( - - )} - - {item.name} - - - )} - /> - )} - - {busy ? ( - - - - ) : null} - + setScreen('settings')} + onOpenCapture={() => setScreen('capture')} + onClose={() => {}} + busy={false} + /> ); } - -const styles = StyleSheet.create({ - root: { flex: 1, backgroundColor: '#fff' }, - header: { - flexDirection: 'row', alignItems: 'center', gap: 12, - paddingHorizontal: 16, paddingVertical: 12, - borderBottomWidth: 1, borderBottomColor: '#000', - }, - title: { flex: 1, fontSize: 22, fontWeight: '600', color: '#000' }, - status: { - fontSize: 12, color: '#444', - paddingHorizontal: 16, paddingVertical: 6, - borderBottomWidth: 1, borderBottomColor: '#ddd', - }, - pathBar: { - flexDirection: 'row', alignItems: 'center', gap: 8, - paddingHorizontal: 12, paddingVertical: 8, - borderBottomWidth: 1, borderBottomColor: '#ccc', - }, - pathTxt: { flex: 1, fontSize: 12, color: '#555' }, - sortBar: { - flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 12, paddingVertical: 8, gap: 8, - borderBottomWidth: 1, borderBottomColor: '#ccc', - }, - sortLabel: { fontSize: 14, color: '#000', marginRight: 4 }, - chip: { paddingHorizontal: 12, paddingVertical: 6, borderWidth: 1, borderColor: '#000' }, - chipActive: { backgroundColor: '#000' }, - chipTxt: { fontSize: 14, color: '#000' }, - chipTxtActive: { color: '#fff' }, - btn: { paddingHorizontal: 14, paddingVertical: 8, borderWidth: 1, borderColor: '#000' }, - btnActive: { backgroundColor: '#000' }, - btnTxt: { fontSize: 14, color: '#000' }, - btnTxtMuted: { color: '#999' }, - btnTxtPrimary: { color: '#fff' }, - grid: { padding: 8 }, - tile: { padding: 6, alignItems: 'center' }, - thumb: { width: '100%', aspectRatio: 1, backgroundColor: '#eee' }, - folderThumb: { - width: '100%', aspectRatio: 1, backgroundColor: '#f4f4f4', - borderWidth: 1, borderColor: '#000', - alignItems: 'center', justifyContent: 'center', - }, - folderIcon: { fontSize: 40 }, - tileName: { marginTop: 4, fontSize: 12, color: '#000', textAlign: 'center' }, - center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }, - empty: { fontSize: 16, color: '#000', marginBottom: 4 }, - emptySub: { fontSize: 13, color: '#444', textAlign: 'center', marginBottom: 12 }, - emptyHint: { fontSize: 12, color: '#666', textAlign: 'center' }, - overlay: { - position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, - backgroundColor: 'rgba(0,0,0,0.4)', - alignItems: 'center', justifyContent: 'center', - }, - previewArea: { - flex: 1, backgroundColor: '#fff', margin: 12, - borderWidth: 1, borderColor: '#000', - alignItems: 'center', justifyContent: 'center', - overflow: 'hidden', - }, - previewImg: { width: '100%', height: '100%' }, - adjustScroll: { maxHeight: 320 }, - adjustScrollContent: { paddingHorizontal: 12, paddingBottom: 4 }, - adjustRow: { paddingVertical: 6, borderTopWidth: 1, borderTopColor: '#eee' }, - adjustHeader: { - flexDirection: 'row', alignItems: 'center', gap: 8, - paddingHorizontal: 4, - }, - adjustLabel: { flex: 1, fontSize: 14, color: '#000' }, - adjustValue: { fontSize: 14, color: '#000', fontVariant: ['tabular-nums'] }, - resetBtn: { - paddingHorizontal: 8, paddingVertical: 4, - borderWidth: 1, borderColor: '#000', - }, - resetBtnTxt: { fontSize: 12, color: '#000' }, - sliderRow: { - flexDirection: 'row', alignItems: 'center', gap: 10, - paddingHorizontal: 4, paddingVertical: 4, - }, - sliderWrap: { flex: 1 }, - sliderTrack: { height: 36, justifyContent: 'center' }, - sliderRail: { - position: 'absolute', left: 0, right: 0, top: 16, height: 4, - backgroundColor: '#ddd', - }, - sliderFill: { - position: 'absolute', left: 0, top: 16, height: 4, - backgroundColor: '#000', - }, - sliderThumb: { - position: 'absolute', top: 6, width: 24, height: 24, - marginLeft: -12, borderRadius: 12, - backgroundColor: '#000', borderWidth: 2, borderColor: '#fff', - }, - stepBtn: { - width: 40, height: 36, alignItems: 'center', justifyContent: 'center', - borderWidth: 1, borderColor: '#000', - }, - actionRow: { - flexDirection: 'row', gap: 12, - paddingHorizontal: 16, paddingVertical: 12, - borderTopWidth: 1, borderTopColor: '#ccc', - }, - actionBtn: { - flex: 1, paddingVertical: 12, - borderWidth: 1, borderColor: '#000', - alignItems: 'center', justifyContent: 'center', - }, - actionBtnPrimary: { backgroundColor: '#000' }, -}); diff --git a/PluginConfig.json b/PluginConfig.json index d0796da..57b8a0f 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.4.2", - "versionCode": "11", + "versionName": "0.5.0", + "versionCode": "12", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/README.md b/README.md index 1b8a379..65435cf 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,14 @@ The preview re-bakes (downsampled to 800 px) ~350 ms after the last slider chang - `PluginConfig.json` — plugin manifest (id, name, icon, version) - `index.js` — registers the sidebar button via `PluginManager.registerButton(1, ['NOTE'], …)` -- `App.tsx` — full-screen picker UI (folder navigator + system-picker handoff, preview with fade / brightness / contrast / gamma sliders) +- `App.tsx` — top-level router. Hosts `streamConfig` and switches between Browser, Preview, Settings, and Capture screens. +- `src/screens/*.tsx` — one file per screen. `Browser` is the folder navigator + system-picker handoff. `Preview` is the static-image preview. `Settings` configures the Mac capture server. `Capture` is the live-stream screen with start/pause, interval steppers, the adjustment panel, a rolling log, and Insert / Replace-in-place. +- `src/AdjustmentPanel.tsx` — tabbed Fade / Brightness / Contrast / Gamma with one slider visible at a time plus preset chips. +- `src/RangeSlider.tsx` — custom PanResponder-based slider (no extra deps). +- `src/imageProcessor.ts`, `src/storage.ts`, `src/embedTracker.ts` — native-module bindings, persistent config helpers, and the element-tracking layer that powers Replace-in-place via `PluginFileAPI.deleteElements` + `insertElements`. - `assets/icon.png` — sidebar button icon (pre-rendered from the source SVG) -- `android/` — RN Android shell. `StubPackage` registers the `ImageProcessor` native module (decode → B/C/gamma LUT → white-overlay → PNG bake, with optional downsample for previews) and forces `buildCustomApkDebug` so the package includes an `app.npk` (required for the plugin's RN runtime to load on-device) +- `android/` — RN Android shell. `StubPackage` registers the `ImageProcessor` native module (decode → B/C/gamma LUT → white-overlay → PNG bake, with optional downsample for previews, plus `downloadAndProcess` for live streaming and SharedPreferences-backed `getConfigValue`/`setConfigValue`) and forces `buildCustomApkDebug` so the package includes an `app.npk` (required for the plugin's RN runtime to load on-device). `AndroidManifest.xml` declares `android:usesCleartextTraffic="true"` so the plugin can fetch over HTTP on the LAN. +- `macapp/` — the Mac-side Python capture server + Tkinter GUI. See `macapp/README.md`. - `buildPlugin.sh` — bundles JS + native APK + manifest into `build/outputs/embedimage.snplg` ## Build & install diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e189252..89ac1d1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" + android:usesCleartextTraffic="true" android:supportsRtl="true"> + FileOutputStream(dlFile).use { out -> input.copyTo(out) } + } + val outPath = bake(dlFile.absolutePath, whiteAlpha, brightness, contrast, gamma, previewMaxDim) + promise.resolve(outPath) + } catch (e: Throwable) { + promise.reject("E_DOWNLOAD", e.message ?: e.toString(), e) + } finally { + dlFile?.delete() + } + } + + private fun bake( + inputPath: String, + whiteAlpha: Double, + brightness: Int, + contrast: Int, + gamma: Double, + previewMaxDim: Int, + ): String { var src: Bitmap? = null try { val decodeOpts = BitmapFactory.Options().apply { @@ -54,7 +106,6 @@ class ImageProcessorModule(reactContext: ReactApplicationContext) : val pixels = IntArray(w * h) src.getPixels(pixels, 0, w, 0, 0, w, h) - // Pre-compute gamma LUT. val g = if (gamma <= 0.0) 1.0 else gamma val invG = 1.0 / g val gammaLut = IntArray(256) @@ -62,7 +113,6 @@ class ImageProcessorModule(reactContext: ReactApplicationContext) : gammaLut[i] = (255.0 * (i / 255.0).pow(invG)).toInt().coerceIn(0, 255) } - // contrast: -100..100 → factor 0..2 around midpoint 128. val contrastFactor = ((contrast.coerceIn(-100, 100) + 100).toDouble()) / 100.0 val brightnessAdjust = brightness.coerceIn(-100, 100) @@ -103,9 +153,7 @@ class ImageProcessorModule(reactContext: ReactApplicationContext) : out.compress(Bitmap.CompressFormat.PNG, 100, stream) } out.recycle() - promise.resolve(outFile.absolutePath) - } catch (e: Throwable) { - promise.reject("E_PROCESS", e.message ?: e.toString(), e) + return outFile.absolutePath } finally { src?.recycle() } @@ -116,8 +164,6 @@ class ImageProcessorModule(reactContext: ReactApplicationContext) : return min(255, max(0, v)) } - // Deletes prev_*.png / embed_*.png left over from previous sessions so - // the cache doesn't grow unbounded across launches. @ReactMethod fun cleanupCache(promise: Promise) { try { @@ -125,7 +171,8 @@ class ImageProcessorModule(reactContext: ReactApplicationContext) : var n = 0 dir.listFiles()?.forEach { f -> val name = f.name - if ((name.startsWith("prev_") || name.startsWith("embed_")) && name.endsWith(".png")) { + if ((name.startsWith("prev_") || name.startsWith("embed_") || name.startsWith("dl_")) && + (name.endsWith(".png") || name.endsWith(".bin"))) { if (f.delete()) n++ } } @@ -134,4 +181,31 @@ class ImageProcessorModule(reactContext: ReactApplicationContext) : promise.reject("E_CLEANUP", e.message ?: e.toString(), e) } } + + // Persistent key/value config backed by SharedPreferences. The plugin's + // settings screen serializes its config as JSON under a single key. + private val prefs by lazy { + reactApplicationContext.getSharedPreferences("embedimage_prefs", android.content.Context.MODE_PRIVATE) + } + + @ReactMethod + fun getConfigValue(key: String, promise: Promise) { + try { + promise.resolve(prefs.getString(key, null)) + } catch (e: Throwable) { + promise.reject("E_CONFIG", e.message ?: e.toString(), e) + } + } + + @ReactMethod + fun setConfigValue(key: String, value: String?, promise: Promise) { + try { + val editor = prefs.edit() + if (value == null) editor.remove(key) else editor.putString(key, value) + editor.apply() + promise.resolve(true) + } catch (e: Throwable) { + promise.reject("E_CONFIG", e.message ?: e.toString(), e) + } + } } diff --git a/macapp/README.md b/macapp/README.md new file mode 100644 index 0000000..fe3e003 --- /dev/null +++ b/macapp/README.md @@ -0,0 +1,59 @@ +# Supernote Screen-Capture Server (Mac) + +Streams a screen / region / window from your Mac to the **Live Capture** +screen of the Embed Image plugin on a Supernote Manta. The plugin polls +this app over HTTP on the LAN — 1 frame per second by default. + +## Run + +```bash +./macapp/run.sh +``` + +The script creates a `macapp/.venv`, installs `requirements.txt`, and +launches the Tk window. On first run macOS will prompt for **Screen +Recording** permission — grant it in System Settings → Privacy & Security +→ Screen & System Audio Recording, then relaunch. + +The window shows: + +- **Connection** — your LAN IP and port. Copy these into the Manta plugin + via Settings → Mac Capture Server. +- **Source** — Full screen, a draggable region, or a specific window from + Quartz's window list (click *Refresh* if you opened a new app). +- **Interval** — seconds per frame (0.2 .. 10). The plugin polls at its + own interval; the lower of the two effectively wins. +- **Start / Stop** — toggle the capture loop. Frames are kept in memory + only; no disk writes. +- **Log** — running activity from both server and GUI. + +Custom port: + +```bash +./macapp/run.sh --port 9100 +``` + +## HTTP API (for debugging) + +| Path | Method | Body | +|-------------|--------|------------------------------------------------| +| `/status` | GET | — | +| `/frame` | GET | latest PNG | +| `/start` | POST | — | +| `/stop` | POST | — | +| `/source` | POST | `{source: "screen"\|"region"\|"window", ...}` | +| `/interval` | POST | `{interval_sec: 1.0}` | +| `/windows` | GET | list of visible windows | + +`curl http://:9000/status` from the Mac confirms the server is +reachable before you point the plugin at it. + +## Why these libraries + +- **mss** — fast, no-fuss screen capture; works on macOS without Quartz + context juggling for full-screen / region grabs. +- **pyobjc-framework-Quartz** — enumerates visible windows so you can pick + by app/title instead of memorising rect coordinates. +- **Pillow** — PNG encoding of the captured frame. +- **Flask** — small HTTP surface served from a worker thread alongside the + Tk main loop. diff --git a/macapp/__init__.py b/macapp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/macapp/__main__.py b/macapp/__main__.py new file mode 100644 index 0000000..fb690b0 --- /dev/null +++ b/macapp/__main__.py @@ -0,0 +1,17 @@ +"""Entry point: `python -m macapp` starts the GUI + capture server.""" +from __future__ import annotations + +import argparse + +from . import gui + + +def main(): + parser = argparse.ArgumentParser(description="Supernote screen-capture server (Mac)") + parser.add_argument("--port", type=int, default=9000, help="HTTP port (default 9000)") + args = parser.parse_args() + gui.main(port=args.port) + + +if __name__ == "__main__": + main() diff --git a/macapp/gui.py b/macapp/gui.py new file mode 100644 index 0000000..900b9d4 --- /dev/null +++ b/macapp/gui.py @@ -0,0 +1,259 @@ +"""Tkinter UI for the capture server. + +Lives in the main thread; the Flask + capture threads from `server.py` run +in the background. The UI lets you pick a source (full screen, region, or +a specific window), start/stop capture, change the interval, see the LAN +IP/port the plugin should target, and watch a rolling log. +""" +from __future__ import annotations + +import threading +import time +import tkinter as tk +from tkinter import ttk + +from . import server + + +# ---- Region picker overlay ---- + +class RegionPicker: + """A topmost semi-transparent window that lets the user drag a rectangle.""" + + def __init__(self, on_done): + self.on_done = on_done + self.root = tk.Toplevel() + self.root.attributes("-fullscreen", True) + self.root.attributes("-alpha", 0.25) + self.root.configure(background="#000") + self.root.attributes("-topmost", True) + self.canvas = tk.Canvas(self.root, bg="#000", highlightthickness=0) + self.canvas.pack(fill="both", expand=True) + self.start = None + self.rect_id = None + self.canvas.bind("", self._on_down) + self.canvas.bind("", self._on_move) + self.canvas.bind("", self._on_up) + self.root.bind("", lambda _: self._cancel()) + + def _on_down(self, e): + self.start = (e.x_root, e.y_root) + if self.rect_id: + self.canvas.delete(self.rect_id) + self.rect_id = self.canvas.create_rectangle( + e.x, e.y, e.x, e.y, outline="#fff", width=2 + ) + + def _on_move(self, e): + if not self.start or not self.rect_id: + return + x0 = self.start[0] - self.root.winfo_rootx() + y0 = self.start[1] - self.root.winfo_rooty() + self.canvas.coords(self.rect_id, x0, y0, e.x, e.y) + + def _on_up(self, e): + if not self.start: + self._cancel() + return + x0, y0 = self.start + x1, y1 = e.x_root, e.y_root + x, y = min(x0, x1), min(y0, y1) + w, h = abs(x1 - x0), abs(y1 - y0) + self.root.destroy() + if w < 20 or h < 20: + self.on_done(None) + else: + self.on_done({"x": int(x), "y": int(y), "w": int(w), "h": int(h)}) + + def _cancel(self): + self.root.destroy() + self.on_done(None) + + +# ---- Main window ---- + +class App: + def __init__(self, port: int): + self.port = port + self.root = tk.Tk() + self.root.title("Supernote Capture Server") + self.root.geometry("520x520") + + self._build_ui() + self._refresh_windows() + self._poll_status() + + def _build_ui(self): + pad = {"padx": 8, "pady": 4} + + # Connection info + info_frame = ttk.LabelFrame(self.root, text="Connection") + info_frame.pack(fill="x", **pad) + self.ip_var = tk.StringVar(value=server.get_local_ip()) + self.port_var = tk.StringVar(value=str(self.port)) + ttk.Label(info_frame, text="LAN IP:").grid(row=0, column=0, sticky="w", padx=4, pady=4) + ttk.Label(info_frame, textvariable=self.ip_var, font=("Menlo", 12, "bold")).grid( + row=0, column=1, sticky="w", padx=4 + ) + ttk.Label(info_frame, text="Port:").grid(row=0, column=2, sticky="e", padx=4) + port_entry = ttk.Entry(info_frame, textvariable=self.port_var, width=8) + port_entry.grid(row=0, column=3, padx=4) + ttk.Button(info_frame, text="Apply", command=self._apply_port).grid(row=0, column=4, padx=4) + ttk.Label( + info_frame, + text="In the Manta plugin: Settings → Mac Capture Server.", + foreground="#555", + ).grid(row=1, column=0, columnspan=5, sticky="w", padx=4, pady=(0, 4)) + + # Source picker + src_frame = ttk.LabelFrame(self.root, text="Source") + src_frame.pack(fill="x", **pad) + self.source_var = tk.StringVar(value="screen") + ttk.Radiobutton( + src_frame, text="Full screen", variable=self.source_var, value="screen", + command=self._send_source, + ).grid(row=0, column=0, sticky="w", padx=4, pady=2) + ttk.Radiobutton( + src_frame, text="Region (drag to pick)", variable=self.source_var, value="region", + command=self._send_source, + ).grid(row=1, column=0, sticky="w", padx=4, pady=2) + ttk.Button(src_frame, text="Pick region…", command=self._pick_region).grid( + row=1, column=1, padx=4 + ) + self.region_label = ttk.Label(src_frame, text="(none)", foreground="#666") + self.region_label.grid(row=1, column=2, sticky="w", padx=4) + ttk.Radiobutton( + src_frame, text="Window:", variable=self.source_var, value="window", + command=self._send_source, + ).grid(row=2, column=0, sticky="w", padx=4, pady=2) + self.window_combo = ttk.Combobox(src_frame, state="readonly", width=48) + self.window_combo.grid(row=2, column=1, columnspan=2, sticky="w", padx=4) + self.window_combo.bind("<>", lambda _: self._send_source()) + ttk.Button(src_frame, text="Refresh", command=self._refresh_windows).grid( + row=2, column=3, padx=4 + ) + + # Interval + int_frame = ttk.LabelFrame(self.root, text="Interval") + int_frame.pack(fill="x", **pad) + self.interval_var = tk.DoubleVar(value=1.0) + ttk.Scale( + int_frame, from_=0.2, to=10.0, orient="horizontal", variable=self.interval_var, + command=lambda _: self._send_interval(), + ).pack(fill="x", padx=4, pady=4) + self.interval_label = ttk.Label(int_frame, text="1.0 s / frame") + self.interval_label.pack(padx=4, pady=(0, 4)) + + # Start/Stop + status + ctl_frame = ttk.Frame(self.root) + ctl_frame.pack(fill="x", **pad) + self.toggle_btn = ttk.Button(ctl_frame, text="Start", command=self._toggle) + self.toggle_btn.pack(side="left", padx=4) + self.status_label = ttk.Label(ctl_frame, text="stopped", foreground="#a00") + self.status_label.pack(side="left", padx=12) + self.frame_label = ttk.Label(ctl_frame, text="0 frames") + self.frame_label.pack(side="right", padx=4) + + # Log + log_frame = ttk.LabelFrame(self.root, text="Log") + log_frame.pack(fill="both", expand=True, **pad) + self.log_box = tk.Text(log_frame, height=10, wrap="word", state="disabled") + self.log_box.pack(fill="both", expand=True, padx=4, pady=4) + + # --- event handlers --- + + def _apply_port(self): + try: + self.port = int(self.port_var.get()) + except ValueError: + return + self._log("Port change requires restart of the app.") + + def _refresh_windows(self): + wins = server.list_windows() + items = [ + f"#{w['id']} [{w['owner']}] {w['title'][:40] or '(untitled)'} {w['w']}×{w['h']}" + for w in wins + ] + self._windows = wins + self.window_combo["values"] = items + if items and not self.window_combo.get(): + self.window_combo.current(0) + + def _pick_region(self): + self.root.iconify() + time.sleep(0.2) + + def done(region): + self.root.deiconify() + if region: + self.region_label.config(text=f"{region['w']}×{region['h']} @ {region['x']},{region['y']}") + self._region = region + self.source_var.set("region") + self._send_source() + else: + self._log("region pick cancelled") + + RegionPicker(done) + + def _send_source(self): + src = self.source_var.get() + if src == "screen": + server.STATE.source = "screen" + server.STATE.monitor_index = 1 + elif src == "region": + r = getattr(self, "_region", None) + if r: + server.STATE.region = r + server.STATE.source = "region" + elif src == "window": + idx = self.window_combo.current() + if idx >= 0 and idx < len(self._windows): + server.STATE.window_id = self._windows[idx]["id"] + server.STATE.source = "window" + self._log(f"source -> {src}") + + def _send_interval(self): + v = round(float(self.interval_var.get()), 2) + server.STATE.interval_sec = v + self.interval_label.config(text=f"{v:.2f} s / frame") + + def _toggle(self): + server.STATE.running = not server.STATE.running + self._log("start" if server.STATE.running else "stop") + + def _log(self, msg: str): + ts = time.strftime("%H:%M:%S") + self.log_box.configure(state="normal") + self.log_box.insert("end", f"{ts} {msg}\n") + self.log_box.see("end") + self.log_box.configure(state="disabled") + + def _poll_status(self): + running = server.STATE.running + self.toggle_btn.configure(text="Stop" if running else "Start") + self.status_label.configure( + text="running" if running else "stopped", + foreground="#080" if running else "#a00", + ) + self.frame_label.configure(text=f"{server.STATE.frame_count} frames") + + # Mirror server-side log into the GUI. + srv_log = list(server.STATE.log) + if hasattr(self, "_log_cursor"): + new = srv_log[self._log_cursor:] + else: + new = srv_log + for entry in new: + self._log(f"[srv] {entry['msg']}") + self._log_cursor = len(srv_log) + + self.root.after(500, self._poll_status) + + def run(self): + self.root.mainloop() + + +def main(port: int): + server.start_background(port) + App(port=port).run() diff --git a/macapp/requirements.txt b/macapp/requirements.txt new file mode 100644 index 0000000..aa7b1e8 --- /dev/null +++ b/macapp/requirements.txt @@ -0,0 +1,4 @@ +Flask>=3.0 +mss>=9.0 +Pillow>=10.0 +pyobjc-framework-Quartz>=10.0 diff --git a/macapp/run.sh b/macapp/run.sh new file mode 100755 index 0000000..eb201c8 --- /dev/null +++ b/macapp/run.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Bootstrap a venv and launch the capture server + GUI. +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +cd "$HERE/.." + +if [[ ! -d macapp/.venv ]]; then + echo "Creating venv at macapp/.venv …" + python3 -m venv macapp/.venv +fi + +# shellcheck disable=SC1091 +source macapp/.venv/bin/activate + +if ! python -c "import flask, mss, PIL, Quartz" >/dev/null 2>&1; then + echo "Installing macapp/requirements.txt …" + pip install --upgrade pip >/dev/null + pip install -r macapp/requirements.txt +fi + +exec python -m macapp "$@" diff --git a/macapp/server.py b/macapp/server.py new file mode 100644 index 0000000..bf28368 --- /dev/null +++ b/macapp/server.py @@ -0,0 +1,275 @@ +"""Capture server + worker for the Supernote Embed Image plugin. + +Runs a small Flask HTTP server that the plugin polls for the most recent +screen capture. A background thread captures the configured source at a +configurable interval and stashes the latest PNG bytes in memory. + +Endpoints +--------- +GET /status -> JSON {running, source, interval_sec, ip, ...} +GET /frame -> PNG bytes of the latest captured frame +POST /start -> begin capture loop +POST /stop -> pause capture loop +POST /source {source, ...} -> change capture source (full screen, region, window) +POST /interval {interval_sec} -> change capture interval +GET /windows -> JSON list of visible windows from Quartz +""" +from __future__ import annotations + +import io +import logging +import socket +import threading +import time +from dataclasses import dataclass, field +from typing import Any, Optional + +import mss +from flask import Flask, jsonify, request +from PIL import Image + +try: + import Quartz # provided by pyobjc-framework-Quartz + HAS_QUARTZ = True +except ImportError: # pragma: no cover + HAS_QUARTZ = False + + +@dataclass +class CaptureState: + running: bool = False + source: str = "screen" # "screen" | "region" | "window" + monitor_index: int = 1 # mss is 1-indexed; 1 = primary + region: Optional[dict] = None # {x, y, w, h} + window_id: Optional[int] = None + interval_sec: float = 1.0 + + last_frame: Optional[bytes] = None + last_frame_ts: float = 0.0 + frame_count: int = 0 + last_error: Optional[str] = None + + log: list = field(default_factory=list) + lock: threading.Lock = field(default_factory=threading.Lock) + + +STATE = CaptureState() + + +def log(msg: str) -> None: + with STATE.lock: + STATE.log.append({"ts": time.time(), "msg": msg}) + if len(STATE.log) > 100: + STATE.log = STATE.log[-100:] + print(f"[capture] {msg}", flush=True) + + +def get_local_ip() -> str: + """Best-effort LAN IP detection without external services.""" + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(("10.255.255.255", 1)) + return s.getsockname()[0] + except Exception: + try: + return socket.gethostbyname(socket.gethostname()) + except Exception: + return "127.0.0.1" + finally: + s.close() + + +def list_windows() -> list: + if not HAS_QUARTZ: + return [] + options = ( + Quartz.kCGWindowListOptionOnScreenOnly + | Quartz.kCGWindowListExcludeDesktopElements + ) + windows = Quartz.CGWindowListCopyWindowInfo(options, Quartz.kCGNullWindowID) + result = [] + for w in windows: + if w.get("kCGWindowLayer", 1) != 0: + continue + bounds = w.get("kCGWindowBounds", {}) + width = int(bounds.get("Width", 0)) + height = int(bounds.get("Height", 0)) + if width < 40 or height < 40: + continue + result.append( + { + "id": int(w.get("kCGWindowNumber", 0)), + "owner": w.get("kCGWindowOwnerName", "") or "", + "title": w.get("kCGWindowName", "") or "", + "x": int(bounds.get("X", 0)), + "y": int(bounds.get("Y", 0)), + "w": width, + "h": height, + } + ) + return result + + +def _window_bounds(window_id: int) -> Optional[dict]: + if not HAS_QUARTZ: + return None + windows = Quartz.CGWindowListCopyWindowInfo( + Quartz.kCGWindowListOptionIncludingWindow, window_id + ) + if not windows: + return None + bounds = windows[0].get("kCGWindowBounds", {}) + return { + "left": int(bounds.get("X", 0)), + "top": int(bounds.get("Y", 0)), + "width": int(bounds.get("Width", 0)), + "height": int(bounds.get("Height", 0)), + } + + +def _capture_once(sct: mss.mss) -> Optional[bytes]: + """Capture according to STATE and return PNG bytes (or None on failure).""" + target: Optional[dict] = None + src = STATE.source + if src == "screen": + monitors = sct.monitors + idx = max(1, min(len(monitors) - 1, STATE.monitor_index)) if len(monitors) > 1 else 0 + target = monitors[idx] + elif src == "region" and STATE.region: + r = STATE.region + target = {"left": r["x"], "top": r["y"], "width": r["w"], "height": r["h"]} + elif src == "window" and STATE.window_id is not None: + target = _window_bounds(STATE.window_id) + if target is None or target["width"] == 0 or target["height"] == 0: + STATE.last_error = "window vanished" + return None + else: + STATE.last_error = "no source" + return None + + shot = sct.grab(target) + pil = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX") + buf = io.BytesIO() + pil.save(buf, format="PNG", optimize=False) + return buf.getvalue() + + +def capture_loop() -> None: + log("capture loop started") + sct = mss.mss() + try: + while True: + if not STATE.running: + time.sleep(0.1) + continue + try: + png = _capture_once(sct) + if png is not None: + with STATE.lock: + STATE.last_frame = png + STATE.last_frame_ts = time.time() + STATE.frame_count += 1 + STATE.last_error = None + except Exception as e: # noqa: BLE001 + STATE.last_error = str(e) + log(f"capture error: {e}") + time.sleep(max(0.05, STATE.interval_sec)) + finally: + sct.close() + + +# ---- Flask app ---- + +app = Flask(__name__) +# Quiet the per-request access log; we have our own log. +logging.getLogger("werkzeug").setLevel(logging.WARNING) + + +@app.route("/status") +def status(): + with STATE.lock: + return jsonify( + { + "running": STATE.running, + "source": STATE.source, + "monitor_index": STATE.monitor_index, + "region": STATE.region, + "window_id": STATE.window_id, + "interval_sec": STATE.interval_sec, + "frame_count": STATE.frame_count, + "has_frame": STATE.last_frame is not None, + "last_frame_ts": STATE.last_frame_ts, + "last_error": STATE.last_error, + "ip": get_local_ip(), + } + ) + + +@app.route("/frame") +def frame(): + with STATE.lock: + if STATE.last_frame is None: + return jsonify({"error": "no frame"}), 404 + body = STATE.last_frame + return body, 200, {"Content-Type": "image/png"} + + +@app.route("/start", methods=["POST"]) +def start(): + STATE.running = True + log("start") + return jsonify({"ok": True}) + + +@app.route("/stop", methods=["POST"]) +def stop(): + STATE.running = False + log("stop") + return jsonify({"ok": True}) + + +@app.route("/source", methods=["POST"]) +def set_source(): + body: dict[str, Any] = request.get_json(force=True, silent=True) or {} + src = body.get("source") + if src not in ("screen", "region", "window"): + return jsonify({"error": "invalid source"}), 400 + STATE.source = src + if src == "screen": + STATE.monitor_index = int(body.get("monitor_index", 1) or 1) + elif src == "region": + STATE.region = body.get("region") + elif src == "window": + STATE.window_id = int(body.get("window_id") or 0) or None + log(f"source -> {src} ({body})") + return jsonify({"ok": True}) + + +@app.route("/interval", methods=["POST"]) +def set_interval(): + body = request.get_json(force=True, silent=True) or {} + try: + v = float(body.get("interval_sec", 1.0)) + except (TypeError, ValueError): + return jsonify({"error": "invalid interval"}), 400 + STATE.interval_sec = max(0.05, min(60.0, v)) + log(f"interval -> {STATE.interval_sec}s") + return jsonify({"ok": True, "interval_sec": STATE.interval_sec}) + + +@app.route("/windows") +def windows(): + return jsonify(list_windows()) + + +def run_server(host: str, port: int) -> None: + from werkzeug.serving import make_server + + log(f"server listening on http://{host}:{port}") + server = make_server(host, port, app, threaded=True) + server.serve_forever() + + +def start_background(port: int) -> None: + threading.Thread(target=capture_loop, daemon=True).start() + threading.Thread(target=run_server, args=("0.0.0.0", port), daemon=True).start() diff --git a/src/AdjustmentPanel.tsx b/src/AdjustmentPanel.tsx new file mode 100644 index 0000000..365b15e --- /dev/null +++ b/src/AdjustmentPanel.tsx @@ -0,0 +1,198 @@ +import React, { useState } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { RangeSlider } from './RangeSlider'; +import type { Adjustments } from './types'; + +type AdjustmentKey = keyof Adjustments; + +type Config = { + label: string; + min: number; + max: number; + step: number; + default: number; + presets: number[]; + format: (v: number) => string; +}; + +const ADJUSTMENT_CONFIG: Record = { + fade: { + label: 'Fade', + min: 0, + max: 100, + step: 5, + default: 0, + presets: [0, 25, 50, 75, 100], + format: (v) => `${Math.round(v)}%`, + }, + brightness: { + label: 'Brightness', + min: -100, + max: 100, + step: 5, + default: 0, + presets: [-50, -25, 0, 25, 50], + format: (v) => `${v > 0 ? '+' : ''}${Math.round(v)}`, + }, + contrast: { + label: 'Contrast', + min: -100, + max: 100, + step: 5, + default: 0, + presets: [-50, -25, 0, 25, 50], + format: (v) => `${v > 0 ? '+' : ''}${Math.round(v)}`, + }, + gamma: { + label: 'Gamma', + min: 0.5, + max: 2.0, + step: 0.05, + default: 1.0, + presets: [0.5, 0.75, 1.0, 1.5, 2.0], + format: (v) => v.toFixed(2), + }, +}; + +function clamp(n: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, n)); +} + +function equalish(a: number, b: number): boolean { + return Math.abs(a - b) < 1e-6; +} + +export function AdjustmentPanel({ + values, + onChange, + disabled, +}: { + values: Adjustments; + onChange: (next: Adjustments) => void; + disabled?: boolean; +}): React.JSX.Element { + const [activeKey, setActiveKey] = useState('fade'); + const cfg = ADJUSTMENT_CONFIG[activeKey]; + const value = values[activeKey]; + const setValue = (v: number) => onChange({ ...values, [activeKey]: v }); + const round = (v: number) => Math.round(v / cfg.step) * cfg.step; + + return ( + + + {(Object.keys(ADJUSTMENT_CONFIG) as AdjustmentKey[]).map((key) => { + const c = ADJUSTMENT_CONFIG[key]; + const active = key === activeKey; + const dirty = !equalish(values[key], c.default); + return ( + setActiveKey(key)} + style={[styles.tab, active && styles.tabActive]} + disabled={disabled} + > + + {c.label} + {dirty ? ' •' : ''} + + + ); + })} + + + + {cfg.label} + {cfg.format(value)} + setValue(cfg.default)} + disabled={disabled} + > + Reset + + + + + setValue(clamp(round(value - cfg.step), cfg.min, cfg.max))} + disabled={disabled} + > + + + + + + setValue(clamp(round(value + cfg.step), cfg.min, cfg.max))} + disabled={disabled} + > + + + + + + + {cfg.presets.map((p) => { + const active = equalish(p, value); + return ( + setValue(p)} + style={[styles.preset, active && styles.presetActive]} + disabled={disabled} + > + + {cfg.format(p)} + + + ); + })} + + + ); +} + +const styles = StyleSheet.create({ + tabRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#000' }, + tab: { + flex: 1, paddingVertical: 10, alignItems: 'center', justifyContent: 'center', + borderRightWidth: 1, borderRightColor: '#000', + }, + tabActive: { backgroundColor: '#000' }, + tabTxt: { fontSize: 14, color: '#000' }, + tabTxtActive: { color: '#fff' }, + header: { + flexDirection: 'row', alignItems: 'center', gap: 8, + paddingHorizontal: 12, paddingTop: 10, + }, + headerLabel: { flex: 1, fontSize: 14, color: '#000' }, + headerValue: { fontSize: 14, color: '#000', fontVariant: ['tabular-nums'] }, + resetBtn: { paddingHorizontal: 10, paddingVertical: 4, borderWidth: 1, borderColor: '#000' }, + resetBtnTxt: { fontSize: 12, color: '#000' }, + sliderRow: { + flexDirection: 'row', alignItems: 'center', gap: 10, + paddingHorizontal: 12, paddingVertical: 6, + }, + sliderWrap: { flex: 1 }, + stepBtn: { + width: 40, height: 36, alignItems: 'center', justifyContent: 'center', + borderWidth: 1, borderColor: '#000', + }, + stepBtnTxt: { fontSize: 18, color: '#000' }, + presetRow: { + flexDirection: 'row', gap: 8, flexWrap: 'wrap', + paddingHorizontal: 12, paddingVertical: 6, + }, + preset: { paddingHorizontal: 10, paddingVertical: 6, borderWidth: 1, borderColor: '#000' }, + presetActive: { backgroundColor: '#000' }, + presetTxt: { fontSize: 13, color: '#000' }, + presetTxtActive: { color: '#fff' }, +}); diff --git a/src/RangeSlider.tsx b/src/RangeSlider.tsx new file mode 100644 index 0000000..35114a5 --- /dev/null +++ b/src/RangeSlider.tsx @@ -0,0 +1,77 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { PanResponder, StyleSheet, View } from 'react-native'; + +function clamp(n: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, n)); +} + +export function RangeSlider({ + value, + min, + max, + step, + onChange, + disabled, +}: { + value: number; + min: number; + max: number; + step: number; + onChange: (v: number) => void; + disabled?: boolean; +}): React.JSX.Element { + const [width, setWidth] = useState(0); + const widthRef = useRef(width); + widthRef.current = width; + + const update = useCallback( + (x: number) => { + const w = Math.max(1, widthRef.current); + const pct = clamp(x / w, 0, 1); + const raw = min + pct * (max - min); + const snapped = Math.round(raw / step) * step; + onChange(clamp(snapped, min, max)); + }, + [min, max, step, onChange], + ); + + const responder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => !disabled, + onMoveShouldSetPanResponder: () => !disabled, + onPanResponderGrant: (e) => update(e.nativeEvent.locationX), + onPanResponderMove: (e) => update(e.nativeEvent.locationX), + }), + ).current; + + const pct = clamp((value - min) / (max - min), 0, 1) * 100; + + return ( + setWidth(e.nativeEvent.layout.width)} + {...responder.panHandlers} + > + + + + + ); +} + +const styles = StyleSheet.create({ + track: { height: 36, justifyContent: 'center' }, + rail: { + position: 'absolute', left: 0, right: 0, top: 16, height: 4, + backgroundColor: '#ddd', + }, + fill: { + position: 'absolute', left: 0, top: 16, height: 4, + backgroundColor: '#000', + }, + thumb: { + position: 'absolute', top: 6, width: 24, height: 24, + marginLeft: -12, borderRadius: 12, + backgroundColor: '#000', borderWidth: 2, borderColor: '#fff', + }, +}); diff --git a/src/embedTracker.ts b/src/embedTracker.ts new file mode 100644 index 0000000..2ff3179 --- /dev/null +++ b/src/embedTracker.ts @@ -0,0 +1,124 @@ +import { PluginCommAPI, PluginFileAPI, PluginNoteAPI } from 'sn-plugin-lib'; +import { EmbedTrack } from './types'; + +// Tracks the most recently embedded Picture element so the live-capture +// flow can replace it in place when a new frame comes in. +// +// Flow on first insert: +// 1. insertImage(path) — uses the host's default position. +// 2. getLastElement() — picks up the new Picture element so we know its +// uuid, numInPage, layerNum, and rect. +// 3. saveCurrentNote(). +// +// Flow on replace: +// 1. getElements() and find ours by uuid — the rect may have moved if the +// user dragged the lasso around in the meantime. +// 2. deleteElements([oldNumInPage]). +// 3. insertElements with a fresh Picture element at the same rect, layer. +// 4. getLastElement() again to refresh the tracker. +// 5. saveCurrentNote(). + +type ApiResponse = { success: boolean; result?: T; error?: { code: number; message: string } }; + +async function getCurrentContext(): Promise<{ notePath: string; page: number } | null> { + try { + const pathRes: any = await PluginCommAPI.getCurrentFilePath(); + const pageRes: any = await PluginCommAPI.getCurrentPageNum(); + const notePath = pathRes?.result ?? pathRes?.filePath ?? pathRes; + const page = pageRes?.result ?? pageRes?.pageNum ?? pageRes; + if (typeof notePath !== 'string' || typeof page !== 'number') return null; + return { notePath, page }; + } catch { + return null; + } +} + +function fromElement(notePath: string, page: number, el: any): EmbedTrack | null { + if (!el || !el.picture) return null; + const rect = el.picture.rect; + if (!rect) return null; + return { + notePath, + page, + layerNum: el.layerNum ?? 0, + numInPage: el.numInPage ?? 0, + uuid: el.uuid ?? '', + rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }, + }; +} + +export async function insertAndTrack(pngPath: string): Promise { + const res: ApiResponse = (await PluginNoteAPI.insertImage(pngPath)) as any; + if (!res || res.success === false) { + throw new Error(res?.error?.message ?? 'insertImage failed'); + } + try { + await PluginNoteAPI.saveCurrentNote(); + } catch {} + const ctx = await getCurrentContext(); + if (!ctx) return null; + try { + const lastRes: any = await PluginFileAPI.getLastElement(); + const el = lastRes?.result ?? lastRes; + return fromElement(ctx.notePath, ctx.page, el); + } catch { + return null; + } +} + +// Re-fetch the tracked element so we pick up any rect changes from the lasso. +async function refreshTrackRect(track: EmbedTrack): Promise { + if (!track.uuid) return track; + try { + const res: any = await PluginFileAPI.getElements(track.page, track.notePath); + const list: any[] = res?.result ?? (Array.isArray(res) ? res : []); + const found = list.find((e) => e?.uuid === track.uuid); + if (!found) return track; + const fresh = fromElement(track.notePath, track.page, found); + return fresh ?? track; + } catch { + return track; + } +} + +export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Promise { + const fresh = await refreshTrackRect(track); + + // Delete the previous embed by its index within the page. + try { + await PluginFileAPI.deleteElements(fresh.notePath, fresh.page, [fresh.numInPage]); + } catch (e: any) { + throw new Error(`delete failed: ${e?.message ?? e}`); + } + + const newElement = { + type: 200, // Element.TYPE_PICTURE + pageNum: fresh.page, + layerNum: fresh.layerNum, + picture: { + picturePath: newPngPath, + rect: { ...fresh.rect }, + }, + }; + + try { + const ins: any = await PluginFileAPI.insertElements(fresh.notePath, fresh.page, [newElement as any]); + if (ins && ins.success === false) { + throw new Error(ins?.error?.message ?? 'insertElements failed'); + } + } catch (e: any) { + throw new Error(`insert failed: ${e?.message ?? e}`); + } + + try { + await PluginNoteAPI.saveCurrentNote(); + } catch {} + + try { + const lastRes: any = await PluginFileAPI.getLastElement(); + const el = lastRes?.result ?? lastRes; + return fromElement(fresh.notePath, fresh.page, el); + } catch { + return null; + } +} diff --git a/src/imageProcessor.ts b/src/imageProcessor.ts new file mode 100644 index 0000000..35525e3 --- /dev/null +++ b/src/imageProcessor.ts @@ -0,0 +1,72 @@ +import { NativeModules } from 'react-native'; +import type { Adjustments } from './types'; + +type ImageProcessorNative = { + processForEmbed: ( + inputPath: string, + whiteAlpha: number, + brightness: number, + contrast: number, + gamma: number, + previewMaxDim: number, + ) => Promise; + downloadAndProcess: ( + url: string, + whiteAlpha: number, + brightness: number, + contrast: number, + gamma: number, + previewMaxDim: number, + timeoutMs: number, + ) => Promise; + cleanupCache: () => Promise; + getConfigValue: (key: string) => Promise; + setConfigValue: (key: string, value: string | null) => Promise; +}; + +const native = (NativeModules as { ImageProcessor?: ImageProcessorNative }).ImageProcessor; + +export const ImageProcessor = native; + +export function adjustmentsAreDefault(a: Adjustments): boolean { + return ( + a.fade === 0 && + a.brightness === 0 && + a.contrast === 0 && + Math.abs(a.gamma - 1.0) < 1e-6 + ); +} + +export async function bakeFile( + inputPath: string, + a: Adjustments, + previewMaxDim: number, +): Promise { + if (!native) throw new Error('ImageProcessor native module missing'); + return native.processForEmbed( + inputPath, + a.fade / 100, + a.brightness, + a.contrast, + a.gamma, + previewMaxDim, + ); +} + +export async function downloadAndBake( + url: string, + a: Adjustments, + previewMaxDim: number, + timeoutMs: number = 5000, +): Promise { + if (!native) throw new Error('ImageProcessor native module missing'); + return native.downloadAndProcess( + url, + a.fade / 100, + a.brightness, + a.contrast, + a.gamma, + previewMaxDim, + timeoutMs, + ); +} diff --git a/src/screens/Browser.tsx b/src/screens/Browser.tsx new file mode 100644 index 0000000..c7e48bc --- /dev/null +++ b/src/screens/Browser.tsx @@ -0,0 +1,410 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + FlatList, + Image, + Pressable, + SafeAreaView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { FileUtils, PluginManager, RattaFileSelector } from 'sn-plugin-lib'; +import type { Entry, EntryKind, SortKey } from '../types'; + +const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.bmp', '.gif', '.webp']; +const INTERNAL_ROOT = '/storage/emulated/0'; +const DEFAULT_DIR = INTERNAL_ROOT + '/Document/Images'; +const MIN_COLUMNS = 2; +const MAX_COLUMNS = 6; +const DEFAULT_COLUMNS = 3; + +const SORT_OPTIONS: { key: SortKey; label: string }[] = [ + { key: 'date_desc', label: 'Newest' }, + { key: 'date_asc', label: 'Oldest' }, + { key: 'name', label: 'Name' }, +]; + +function basename(path: string): string { + const i = path.lastIndexOf('/'); + return i >= 0 ? path.slice(i + 1) : path; +} + +function parentDir(p: string): string { + const trimmed = p.replace(/\/+$/, ''); + const i = trimmed.lastIndexOf('/'); + if (i <= 0) return '/'; + return trimmed.slice(0, i); +} + +function classifyEntry(name: string): EntryKind | null { + const lower = name.toLowerCase(); + if (IMAGE_EXTS.some((e) => lower.endsWith(e))) return 'image'; + const dot = lower.lastIndexOf('.'); + if (dot <= 0) return 'folder'; + return null; +} + +function isPng(name: string): boolean { + return name.toLowerCase().endsWith('.png'); +} + +function sortEntries(items: Entry[], sort: SortKey): Entry[] { + const folders = items.filter((e) => e.kind === 'folder'); + const images = items.filter((e) => e.kind === 'image'); + const byName = (a: Entry, b: Entry) => + a.name.localeCompare(b.name, undefined, { numeric: true }); + folders.sort(byName); + if (sort === 'name') images.sort(byName); + else if (sort === 'date_asc') images.sort(byName); + else images.sort((a, b) => byName(b, a)); + return folders.concat(images); +} + +function clamp(n: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, n)); +} + +export function BrowserScreen({ + onPickFile, + onOpenSettings, + onOpenCapture, + onClose, + busy, +}: { + onPickFile: (entry: Entry) => void; + onOpenSettings: () => void; + onOpenCapture: () => void; + onClose: () => void; + busy: boolean; +}): React.JSX.Element { + const [currentDir, setCurrentDir] = useState(DEFAULT_DIR); + const [entries, setEntries] = useState([]); + const [sort, setSort] = useState('date_desc'); + const [columns, setColumns] = useState(DEFAULT_COLUMNS); + const [sdRoots, setSdRoots] = useState([]); + const [status, setStatus] = useState('starting…'); + + const load = useCallback(async (dir: string) => { + setStatus(`listing ${dir}`); + try { + let exists = false; + try { + exists = await FileUtils.exists(dir); + } catch (e: any) { + setStatus(`exists() threw: ${e?.message ?? e}`); + return; + } + if (!exists) { + if (dir === DEFAULT_DIR) { + try { + await FileUtils.makeDir(dir); + } catch (e: any) { + setStatus(`makeDir() threw: ${e?.message ?? e}`); + return; + } + } else { + setStatus(`directory missing: ${dir}`); + setEntries([]); + return; + } + } + + let raw: any = null; + try { + raw = await FileUtils.listFiles(dir); + } catch (e: any) { + setStatus(`listFiles() threw: ${e?.message ?? e}`); + return; + } + + const list: any[] = Array.isArray(raw) ? raw : []; + const out: Entry[] = []; + for (const entry of list) { + const path = typeof entry === 'string' ? entry : entry?.path; + const sdkType = typeof entry === 'string' ? undefined : entry?.type; + if (!path) continue; + const name = basename(path); + if (!name || name.startsWith('.')) continue; + let kind: EntryKind | null; + if (sdkType === 0) kind = 'folder'; + else if (sdkType === 1) kind = isPng(name) || classifyEntry(name) === 'image' ? 'image' : null; + else kind = classifyEntry(name); + if (!kind) continue; + out.push({ name, path, kind }); + } + setEntries(out); + const imgs = out.filter((e) => e.kind === 'image').length; + const dirs = out.filter((e) => e.kind === 'folder').length; + setStatus(`${imgs} image${imgs === 1 ? '' : 's'}, ${dirs} folder${dirs === 1 ? '' : 's'}`); + } catch (err: any) { + setStatus(`unexpected: ${err?.message ?? err}`); + } + }, []); + + useEffect(() => { + load(currentDir); + }, [currentDir, load]); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res: any = await (FileUtils as any).getExternalDirPath?.(); + if (cancelled) return; + const list: string[] = Array.isArray(res) + ? res.filter((p) => typeof p === 'string' && p && p !== INTERNAL_ROOT) + : []; + setSdRoots(list); + } catch { + // device may not expose any external storage; ignore. + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const allRoots = useMemo(() => [INTERNAL_ROOT, ...sdRoots], [sdRoots]); + const currentRoot = useMemo(() => { + return ( + allRoots.find((r) => currentDir === r || currentDir.startsWith(r + '/')) ?? INTERNAL_ROOT + ); + }, [allRoots, currentDir]); + + const sorted = useMemo(() => sortEntries(entries, sort), [entries, sort]); + + const onPickEntry = useCallback( + (entry: Entry) => { + if (entry.kind === 'folder') { + setCurrentDir(entry.path); + } else { + onPickFile(entry); + } + }, + [onPickFile], + ); + + const onSystemPicker = useCallback(async () => { + try { + setStatus('opening system file picker…'); + const res: any = await RattaFileSelector.selectFile({ + selectType: 1, + suffixList: ['png', 'jpg', 'jpeg', 'bmp', 'gif', 'webp'], + maxNum: 1, + title: 'Pick image', + }); + const path: string | undefined = Array.isArray(res) ? res[0] : res; + if (!path) { + setStatus('picker cancelled'); + return; + } + const name = basename(path); + onPickFile({ name, path, kind: 'image' }); + } catch (e: any) { + setStatus(`picker failed: ${e?.message ?? e}`); + } + }, [onPickFile]); + + const navigateUp = useCallback(() => { + if (currentDir === currentRoot) return; + const p = parentDir(currentDir); + if (p === currentRoot || p.startsWith(currentRoot + '/')) { + setCurrentDir(p); + } else { + setCurrentDir(currentRoot); + } + }, [currentDir, currentRoot]); + + const goHome = useCallback(() => setCurrentDir(DEFAULT_DIR), []); + const canGoUp = currentDir !== currentRoot; + + return ( + + + Embed Image + + Live… + + + Settings + + { + onClose(); + PluginManager.closePluginView().catch(() => {}); + }} + > + Close + + + + {status} + + + + Up + + + Home + + setCurrentDir(INTERNAL_ROOT)} + > + + Internal + + + {sdRoots.map((r, i) => { + const active = currentRoot === r; + const label = sdRoots.length > 1 ? `SD ${i + 1}` : 'SD'; + return ( + setCurrentDir(r)} + > + {label} + + ); + })} + {currentDir} + + + + Sort: + {SORT_OPTIONS.map((opt) => { + const active = opt.key === sort; + return ( + setSort(opt.key)} + style={[styles.chip, active && styles.chipActive]} + > + {opt.label} + + ); + })} + + Size: + setColumns((c) => clamp(c + 1, MIN_COLUMNS, MAX_COLUMNS))} + style={styles.btn} + disabled={columns >= MAX_COLUMNS} + > + = MAX_COLUMNS && styles.btnTxtMuted]}>− + + setColumns((c) => clamp(c - 1, MIN_COLUMNS, MAX_COLUMNS))} + style={styles.btn} + disabled={columns <= MIN_COLUMNS} + > + + + + + Browse… + + load(currentDir)} style={styles.btn}> + Refresh + + + + {sorted.length === 0 ? ( + + Nothing here. + {currentDir} + + Tap “Browse…” for the system picker (WebDAV + SD), “Live…” to stream from a Mac. + + + ) : ( + it.path} + numColumns={columns} + contentContainerStyle={styles.grid} + renderItem={({ item }) => ( + onPickEntry(item)} + disabled={busy} + > + {item.kind === 'folder' ? ( + + 📁 + + ) : ( + + )} + {item.name} + + )} + /> + )} + + {busy ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: '#fff' }, + header: { + flexDirection: 'row', alignItems: 'center', gap: 8, + paddingHorizontal: 16, paddingVertical: 12, + borderBottomWidth: 1, borderBottomColor: '#000', + }, + title: { flex: 1, fontSize: 22, fontWeight: '600', color: '#000' }, + status: { + fontSize: 12, color: '#444', + paddingHorizontal: 16, paddingVertical: 6, + borderBottomWidth: 1, borderBottomColor: '#ddd', + }, + pathBar: { + flexDirection: 'row', alignItems: 'center', gap: 8, + paddingHorizontal: 12, paddingVertical: 8, + borderBottomWidth: 1, borderBottomColor: '#ccc', + }, + pathTxt: { flex: 1, fontSize: 12, color: '#555' }, + sortBar: { + flexDirection: 'row', alignItems: 'center', + paddingHorizontal: 12, paddingVertical: 8, gap: 8, + borderBottomWidth: 1, borderBottomColor: '#ccc', + }, + sortLabel: { fontSize: 14, color: '#000', marginRight: 4 }, + chip: { paddingHorizontal: 12, paddingVertical: 6, borderWidth: 1, borderColor: '#000' }, + chipActive: { backgroundColor: '#000' }, + chipTxt: { fontSize: 14, color: '#000' }, + chipTxtActive: { color: '#fff' }, + btn: { paddingHorizontal: 14, paddingVertical: 8, borderWidth: 1, borderColor: '#000' }, + btnActive: { backgroundColor: '#000' }, + btnTxt: { fontSize: 14, color: '#000' }, + btnTxtMuted: { color: '#999' }, + btnTxtPrimary: { color: '#fff' }, + grid: { padding: 8 }, + tile: { padding: 6, alignItems: 'center' }, + thumb: { width: '100%', aspectRatio: 1, backgroundColor: '#eee' }, + folderThumb: { + width: '100%', aspectRatio: 1, backgroundColor: '#f4f4f4', + borderWidth: 1, borderColor: '#000', + alignItems: 'center', justifyContent: 'center', + }, + folderIcon: { fontSize: 40 }, + tileName: { marginTop: 4, fontSize: 12, color: '#000', textAlign: 'center' }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }, + empty: { fontSize: 16, color: '#000', marginBottom: 4 }, + emptySub: { fontSize: 13, color: '#444', textAlign: 'center', marginBottom: 12 }, + emptyHint: { fontSize: 12, color: '#666', textAlign: 'center' }, + overlay: { + position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, + backgroundColor: 'rgba(0,0,0,0.4)', + alignItems: 'center', justifyContent: 'center', + }, +}); diff --git a/src/screens/Capture.tsx b/src/screens/Capture.tsx new file mode 100644 index 0000000..361dbf4 --- /dev/null +++ b/src/screens/Capture.tsx @@ -0,0 +1,368 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + ActivityIndicator, + Image, + Pressable, + SafeAreaView, + ScrollView, + StyleSheet, + Text, + ToastAndroid, + View, +} from 'react-native'; +import { PluginManager } from 'sn-plugin-lib'; +import { AdjustmentPanel } from '../AdjustmentPanel'; +import { downloadAndBake } from '../imageProcessor'; +import { insertAndTrack, replaceInPlace } from '../embedTracker'; +import { baseUrl, saveStreamConfig } from '../storage'; +import { + Adjustments, + DEFAULT_ADJUSTMENTS, + EmbedTrack, + LogEntry, + StreamConfig, +} from '../types'; + +const PREVIEW_MAX_DIM = 800; +const MIN_INTERVAL_SEC = 0.2; +const MAX_INTERVAL_SEC = 60; +const MAX_LOG_LINES = 12; +const INTERVAL_STEP = 0.5; + +function clamp(n: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, n)); +} + +function nowHHMMSS(): string { + const d = new Date(); + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +export function CaptureScreen({ + config, + onConfigChange, + onBack, + onOpenSettings, +}: { + config: StreamConfig; + onConfigChange: (cfg: StreamConfig) => void; + onBack: () => void; + onOpenSettings: () => void; +}): React.JSX.Element { + const [adjustments, setAdjustments] = useState(DEFAULT_ADJUSTMENTS); + const [capturing, setCapturing] = useState(false); + const [framePath, setFramePath] = useState(null); + const [lastFrameTs, setLastFrameTs] = useState(0); + const [logs, setLogs] = useState([]); + const [busy, setBusy] = useState(false); + const [track, setTrack] = useState(null); + const [intervalSec, setIntervalSec] = useState(config.intervalSec); + const inFlight = useRef(false); + + const pushLog = useCallback((msg: string) => { + setLogs((prev) => { + const next = prev.concat([{ ts: Date.now(), msg }]); + return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next; + }); + }, []); + + const url = baseUrl(config); + const adjustmentsRef = useRef(adjustments); + adjustmentsRef.current = adjustments; + + const fetchOnce = useCallback(async (): Promise => { + if (!url) { + pushLog('no server configured — open Settings'); + return null; + } + if (inFlight.current) return null; + inFlight.current = true; + try { + const path = await downloadAndBake( + `${url}/frame`, + adjustmentsRef.current, + PREVIEW_MAX_DIM, + Math.max(2000, Math.round(intervalSec * 1500)), + ); + setFramePath(path); + setLastFrameTs(Date.now()); + return path; + } catch (e: any) { + pushLog(`fetch failed: ${e?.message ?? e}`); + return null; + } finally { + inFlight.current = false; + } + }, [url, intervalSec, pushLog]); + + // Capture polling loop. + useEffect(() => { + if (!capturing) return; + let alive = true; + let timer: ReturnType | null = null; + const tick = async () => { + if (!alive) return; + await fetchOnce(); + if (!alive) return; + timer = setTimeout(tick, Math.max(MIN_INTERVAL_SEC, intervalSec) * 1000); + }; + pushLog(`start @ ${intervalSec}s — ${url || '(no server)'}`); + tick(); + return () => { + alive = false; + if (timer) clearTimeout(timer); + pushLog('pause'); + }; + }, [capturing, intervalSec, fetchOnce, pushLog, url]); + + // Re-bake the most recent raw frame when adjustments change while paused. + // (When running, the next tick naturally picks up new adjustments.) + // Skipped to keep the implementation simple — adjustments take effect on + // the next captured frame. + + const onTogglePolling = useCallback(() => { + setCapturing((c) => !c); + }, []); + + const onCheckStatus = useCallback(async () => { + if (!url) { + pushLog('no server configured'); + return; + } + try { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 3000); + const r = await fetch(`${url}/status`, { signal: ctrl.signal }); + clearTimeout(t); + const j = await r.json(); + pushLog(`status: running=${j.running}, src=${j.source}, ip=${j.ip ?? '?'}`); + } catch (e: any) { + pushLog(`status failed: ${e?.message ?? e}`); + } + }, [url, pushLog]); + + const changeInterval = useCallback( + (delta: number) => { + const next = clamp( + Math.round((intervalSec + delta) * 10) / 10, + MIN_INTERVAL_SEC, + MAX_INTERVAL_SEC, + ); + if (next === intervalSec) return; + setIntervalSec(next); + const cfg = { ...config, intervalSec: next }; + onConfigChange(cfg); + saveStreamConfig(cfg).catch(() => {}); + }, + [intervalSec, config, onConfigChange], + ); + + const onInsert = useCallback(async () => { + if (busy) return; + setBusy(true); + pushLog('insert: baking full-res…'); + try { + const fullPath = await downloadAndBake(`${url}/frame`, adjustmentsRef.current, 0, 8000); + const newTrack = await insertAndTrack(fullPath); + if (newTrack) { + setTrack(newTrack); + pushLog(`insert ok — num=${newTrack.numInPage}`); + } else { + pushLog('insert ok — couldn’t resolve element (Replace disabled)'); + } + try { + ToastAndroid.showWithGravity('Inserted frame', ToastAndroid.SHORT, ToastAndroid.BOTTOM); + } catch {} + } catch (e: any) { + pushLog(`insert failed: ${e?.message ?? e}`); + } finally { + setBusy(false); + } + }, [busy, url, pushLog]); + + const onReplace = useCallback(async () => { + if (busy || !track) return; + setBusy(true); + pushLog('replace: baking full-res…'); + try { + const fullPath = await downloadAndBake(`${url}/frame`, adjustmentsRef.current, 0, 8000); + const newTrack = await replaceInPlace(track, fullPath); + if (newTrack) { + setTrack(newTrack); + pushLog(`replace ok — num=${newTrack.numInPage}`); + } else { + pushLog('replace done — tracker reset'); + setTrack(null); + } + } catch (e: any) { + pushLog(`replace failed: ${e?.message ?? e}`); + } finally { + setBusy(false); + } + }, [busy, track, url, pushLog]); + + const onClose = useCallback(() => { + setCapturing(false); + setTrack(null); + onBack(); + }, [onBack]); + + const sourceUri = framePath ? 'file://' + framePath : null; + const ageSec = lastFrameTs ? Math.round((Date.now() - lastFrameTs) / 1000) : null; + + return ( + + + + Back + + Live Capture + + Settings + + + + + {url || '(no server set)'} · interval {intervalSec.toFixed(1)}s ·{' '} + {lastFrameTs ? `last frame ${ageSec}s ago` : 'no frame yet'} + {track ? ' · embedded' : ''} + + + + {sourceUri ? ( + + ) : ( + + {capturing ? 'waiting for first frame…' : 'tap Start to begin streaming'} + + )} + + + + + + {capturing ? 'Pause' : 'Start'} + + + fetchOnce()} disabled={busy || !url}> + Pull once + + + Status + + + changeInterval(-INTERVAL_STEP)} disabled={busy}> + + + {intervalSec.toFixed(1)}s + changeInterval(+INTERVAL_STEP)} disabled={busy}> + + + + + + + + + + {logs.length === 0 ? ( + logs will appear here… + ) : ( + logs.map((e, i) => ( + + {nowHHMMSS()} {e.msg} + + )) + )} + + + + + + Close + + + Insert + + + Replace + + + + {busy ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: '#fff' }, + header: { + flexDirection: 'row', alignItems: 'center', gap: 12, + paddingHorizontal: 16, paddingVertical: 12, + borderBottomWidth: 1, borderBottomColor: '#000', + }, + title: { flex: 1, fontSize: 22, fontWeight: '600', color: '#000' }, + status: { + fontSize: 12, color: '#444', + paddingHorizontal: 16, paddingVertical: 6, + borderBottomWidth: 1, borderBottomColor: '#ddd', + }, + btn: { paddingHorizontal: 14, paddingVertical: 8, borderWidth: 1, borderColor: '#000' }, + btnActive: { backgroundColor: '#000' }, + btnTxt: { fontSize: 14, color: '#000' }, + btnTxtPrimary: { color: '#fff' }, + previewArea: { + flex: 1, backgroundColor: '#fff', margin: 12, + borderWidth: 1, borderColor: '#000', + alignItems: 'center', justifyContent: 'center', + overflow: 'hidden', + }, + previewImg: { width: '100%', height: '100%' }, + placeholder: { fontSize: 14, color: '#666' }, + controlBar: { + flexDirection: 'row', alignItems: 'center', gap: 8, + paddingHorizontal: 12, paddingVertical: 8, + borderTopWidth: 1, borderTopColor: '#ccc', + }, + intervalLabel: { fontSize: 14, color: '#000', minWidth: 48, textAlign: 'center', fontVariant: ['tabular-nums'] }, + logBox: { + height: 110, marginHorizontal: 12, marginBottom: 8, + borderWidth: 1, borderColor: '#bbb', backgroundColor: '#fafafa', + }, + logScroll: { padding: 6 }, + logEmpty: { fontSize: 11, color: '#888' }, + logLine: { fontSize: 11, color: '#222', fontFamily: 'monospace' }, + actionRow: { + flexDirection: 'row', gap: 12, + paddingHorizontal: 16, paddingVertical: 12, + borderTopWidth: 1, borderTopColor: '#ccc', + }, + actionBtn: { + flex: 1, paddingVertical: 12, + borderWidth: 1, borderColor: '#000', + alignItems: 'center', justifyContent: 'center', + }, + actionBtnPrimary: { backgroundColor: '#000' }, + actionBtnDisabled: { borderColor: '#ccc' }, + overlay: { + position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, + backgroundColor: 'rgba(0,0,0,0.4)', + alignItems: 'center', justifyContent: 'center', + }, +}); diff --git a/src/screens/Preview.tsx b/src/screens/Preview.tsx new file mode 100644 index 0000000..3b71e0c --- /dev/null +++ b/src/screens/Preview.tsx @@ -0,0 +1,177 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + ActivityIndicator, + Image, + Pressable, + SafeAreaView, + StyleSheet, + Text, + ToastAndroid, + View, +} from 'react-native'; +import { PluginManager, PluginNoteAPI } from 'sn-plugin-lib'; +import { AdjustmentPanel } from '../AdjustmentPanel'; +import { adjustmentsAreDefault, bakeFile } from '../imageProcessor'; +import { Adjustments, DEFAULT_ADJUSTMENTS, Entry } from '../types'; + +const PREVIEW_MAX_DIM = 800; + +function isPng(name: string): boolean { + return name.toLowerCase().endsWith('.png'); +} + +export function PreviewScreen({ + entry, + onBack, +}: { + entry: Entry; + onBack: () => void; +}): React.JSX.Element { + const [adjustments, setAdjustments] = useState(DEFAULT_ADJUSTMENTS); + const [previewPath, setPreviewPath] = useState(null); + const [previewing, setPreviewing] = useState(false); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(entry.name); + + // Debounced preview bake. + useEffect(() => { + const allDefault = adjustmentsAreDefault(adjustments); + if (allDefault && isPng(entry.name)) { + setPreviewPath(null); + return; + } + let cancelled = false; + const t = setTimeout(() => { + setPreviewing(true); + bakeFile(entry.path, adjustments, PREVIEW_MAX_DIM) + .then((p) => { + if (!cancelled) setPreviewPath(p); + }) + .catch((e: any) => { + if (!cancelled) setStatus(`preview failed: ${e?.message ?? e}`); + }) + .finally(() => { + if (!cancelled) setPreviewing(false); + }); + }, 350); + return () => { + cancelled = true; + clearTimeout(t); + }; + }, [entry, adjustments]); + + const onInsert = useCallback(async () => { + if (busy) return; + setBusy(true); + setStatus(`embedding ${entry.name}…`); + try { + const allDefault = adjustmentsAreDefault(adjustments); + const needsBake = !allDefault || !isPng(entry.name); + let pathToInsert = entry.path; + if (needsBake) { + try { + pathToInsert = await bakeFile(entry.path, adjustments, 0); + } catch (e: any) { + setStatus(`process failed: ${e?.message ?? e}`); + return; + } + } + const res: any = await PluginNoteAPI.insertImage(pathToInsert); + if (!res || res.success === false) { + setStatus(`insert failed: ${res?.error?.message ?? 'unknown'}`); + return; + } + try { await PluginNoteAPI.saveCurrentNote(); } catch {} + try { + ToastAndroid.showWithGravity(`Embedded ${entry.name}`, ToastAndroid.SHORT, ToastAndroid.BOTTOM); + } catch {} + PluginManager.closePluginView().catch(() => {}); + } catch (err: any) { + setStatus(`insert threw: ${err?.message ?? err}`); + } finally { + setBusy(false); + } + }, [entry, adjustments, busy]); + + const sourceUri = 'file://' + (previewPath ?? entry.path); + + return ( + + + + Back + + {entry.name} + {previewing ? : null} + + + {status} + + + + + + + + + + Cancel + + + Insert + + + + {busy ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: '#fff' }, + header: { + flexDirection: 'row', alignItems: 'center', gap: 12, + paddingHorizontal: 16, paddingVertical: 12, + borderBottomWidth: 1, borderBottomColor: '#000', + }, + title: { flex: 1, fontSize: 22, fontWeight: '600', color: '#000' }, + status: { + fontSize: 12, color: '#444', + paddingHorizontal: 16, paddingVertical: 6, + borderBottomWidth: 1, borderBottomColor: '#ddd', + }, + btn: { paddingHorizontal: 14, paddingVertical: 8, borderWidth: 1, borderColor: '#000' }, + btnTxt: { fontSize: 14, color: '#000' }, + btnTxtPrimary: { color: '#fff' }, + previewArea: { + flex: 1, backgroundColor: '#fff', margin: 12, + borderWidth: 1, borderColor: '#000', + alignItems: 'center', justifyContent: 'center', + overflow: 'hidden', + }, + previewImg: { width: '100%', height: '100%' }, + actionRow: { + flexDirection: 'row', gap: 12, + paddingHorizontal: 16, paddingVertical: 12, + borderTopWidth: 1, borderTopColor: '#ccc', + }, + actionBtn: { + flex: 1, paddingVertical: 12, + borderWidth: 1, borderColor: '#000', + alignItems: 'center', justifyContent: 'center', + }, + actionBtnPrimary: { backgroundColor: '#000' }, + overlay: { + position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, + backgroundColor: 'rgba(0,0,0,0.4)', + alignItems: 'center', justifyContent: 'center', + }, +}); diff --git a/src/screens/Settings.tsx b/src/screens/Settings.tsx new file mode 100644 index 0000000..5d817ed --- /dev/null +++ b/src/screens/Settings.tsx @@ -0,0 +1,192 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + ActivityIndicator, + Pressable, + SafeAreaView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { baseUrl, loadStreamConfig, saveStreamConfig } from '../storage'; +import { DEFAULT_STREAM_CONFIG, StreamConfig } from '../types'; + +function clamp(n: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, n)); +} + +export function SettingsScreen({ + onBack, + onSaved, +}: { + onBack: () => void; + onSaved: (cfg: StreamConfig) => void; +}): React.JSX.Element { + const [host, setHost] = useState(DEFAULT_STREAM_CONFIG.host); + const [port, setPort] = useState(String(DEFAULT_STREAM_CONFIG.port)); + const [intervalSec, setIntervalSec] = useState(String(DEFAULT_STREAM_CONFIG.intervalSec)); + const [status, setStatus] = useState('loading…'); + const [busy, setBusy] = useState(false); + + useEffect(() => { + (async () => { + const cfg = await loadStreamConfig(); + setHost(cfg.host); + setPort(String(cfg.port)); + setIntervalSec(String(cfg.intervalSec)); + setStatus(cfg.host ? `loaded: ${baseUrl(cfg)}` : 'no server configured'); + })(); + }, []); + + const onTest = useCallback(async () => { + setStatus('testing…'); + try { + const portNum = clamp(parseInt(port, 10) || 0, 1, 65535); + const url = `http://${host.trim()}:${portNum}/status`; + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 4000); + const res = await fetch(url, { signal: ctrl.signal }); + clearTimeout(timer); + if (!res.ok) { + setStatus(`HTTP ${res.status}`); + return; + } + const json = await res.json(); + setStatus(`ok — running=${json.running}, source=${json.source}, ip=${json.ip ?? '?'}`); + } catch (e: any) { + setStatus(`failed: ${e?.message ?? e}`); + } + }, [host, port]); + + const onSave = useCallback(async () => { + setBusy(true); + try { + const portNum = clamp(parseInt(port, 10) || DEFAULT_STREAM_CONFIG.port, 1, 65535); + const intervalNum = clamp(parseFloat(intervalSec) || DEFAULT_STREAM_CONFIG.intervalSec, 0.2, 60); + const cfg: StreamConfig = { host: host.trim(), port: portNum, intervalSec: intervalNum }; + await saveStreamConfig(cfg); + setStatus(`saved: ${baseUrl(cfg)}`); + onSaved(cfg); + } catch (e: any) { + setStatus(`save failed: ${e?.message ?? e}`); + } finally { + setBusy(false); + } + }, [host, port, intervalSec, onSaved]); + + return ( + + + + Back + + Settings + + + {status} + + + Mac Capture Server + + Set the IP shown by the Mac app, plus its port (default 9000). The plugin will fetch + frames from http://<host>:<port>/frame. + + + + Host (Mac IP) + + + + + Port + + + + + Frame interval (seconds) + + 0.2 .. 60 seconds. 1.0 = 1 fps (recommended for e-ink). + + + + + Test connection + + + Save + + + + + {busy ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: '#fff' }, + header: { + flexDirection: 'row', alignItems: 'center', gap: 12, + paddingHorizontal: 16, paddingVertical: 12, + borderBottomWidth: 1, borderBottomColor: '#000', + }, + title: { flex: 1, fontSize: 22, fontWeight: '600', color: '#000' }, + status: { + fontSize: 12, color: '#444', + paddingHorizontal: 16, paddingVertical: 6, + borderBottomWidth: 1, borderBottomColor: '#ddd', + }, + btn: { paddingHorizontal: 14, paddingVertical: 8, borderWidth: 1, borderColor: '#000' }, + btnTxt: { fontSize: 14, color: '#000' }, + btnTxtPrimary: { color: '#fff' }, + body: { padding: 16, gap: 12 }, + section: { fontSize: 16, fontWeight: '600', color: '#000', marginBottom: 4 }, + hint: { fontSize: 12, color: '#666' }, + field: { gap: 4 }, + label: { fontSize: 13, color: '#000' }, + input: { + borderWidth: 1, borderColor: '#000', + paddingHorizontal: 12, paddingVertical: 8, + fontSize: 14, color: '#000', + }, + row: { flexDirection: 'row', gap: 12, marginTop: 12 }, + actionBtn: { + flex: 1, paddingVertical: 12, + borderWidth: 1, borderColor: '#000', + alignItems: 'center', justifyContent: 'center', + }, + actionBtnPrimary: { backgroundColor: '#000' }, + overlay: { + position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, + backgroundColor: 'rgba(0,0,0,0.4)', + alignItems: 'center', justifyContent: 'center', + }, +}); diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..3ba0789 --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,32 @@ +import { ImageProcessor } from './imageProcessor'; +import { DEFAULT_STREAM_CONFIG, StreamConfig } from './types'; + +const STREAM_CONFIG_KEY = 'streamConfig'; + +export async function loadStreamConfig(): Promise { + if (!ImageProcessor?.getConfigValue) return DEFAULT_STREAM_CONFIG; + try { + const raw = await ImageProcessor.getConfigValue(STREAM_CONFIG_KEY); + if (!raw) return DEFAULT_STREAM_CONFIG; + const parsed = JSON.parse(raw); + return { + host: typeof parsed.host === 'string' ? parsed.host : '', + port: typeof parsed.port === 'number' ? parsed.port : DEFAULT_STREAM_CONFIG.port, + intervalSec: + typeof parsed.intervalSec === 'number' ? parsed.intervalSec : DEFAULT_STREAM_CONFIG.intervalSec, + }; + } catch { + return DEFAULT_STREAM_CONFIG; + } +} + +export async function saveStreamConfig(cfg: StreamConfig): Promise { + if (!ImageProcessor?.setConfigValue) return; + await ImageProcessor.setConfigValue(STREAM_CONFIG_KEY, JSON.stringify(cfg)); +} + +export function baseUrl(cfg: StreamConfig): string { + const host = cfg.host.trim(); + if (!host) return ''; + return `http://${host}:${cfg.port}`; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..b0ca5d9 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,41 @@ +export type SortKey = 'date_desc' | 'date_asc' | 'name'; +export type EntryKind = 'image' | 'folder'; +export type Entry = { name: string; path: string; kind: EntryKind }; +export type Screen = 'browser' | 'preview' | 'settings' | 'capture'; + +export type Adjustments = { + fade: number; + brightness: number; + contrast: number; + gamma: number; +}; + +export const DEFAULT_ADJUSTMENTS: Adjustments = { + fade: 0, + brightness: 0, + contrast: 0, + gamma: 1.0, +}; + +export type StreamConfig = { + host: string; + port: number; + intervalSec: number; +}; + +export const DEFAULT_STREAM_CONFIG: StreamConfig = { + host: '', + port: 9000, + intervalSec: 1.0, +}; + +export type EmbedTrack = { + notePath: string; + page: number; + layerNum: number; + numInPage: number; + uuid: string; + rect: { left: number; top: number; right: number; bottom: number }; +}; + +export type LogEntry = { ts: number; msg: string }; From df54eeebf38f92c961fd6617b61fe6d61f2b530f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 09:04:28 +0000 Subject: [PATCH 06/28] Fix plugin crash on new Manta beta; move stream adjustments to Mac side Crash fix - Drop the FileUtils.getExternalDirPath() probe in Browser.tsx. On the latest Manta beta firmware getCurrentActivity() returns null when the JS bundle first runs, so the native call throws an NPE that escapes the SDK module (its try/catch starts after the throwing line), trips PluginJSExceptionHandler, and tears down the RN bridge before our await even rejects. SD-card and WebDAV access still work through the "Browse..." system picker. Stream adjustments on the Mac side - Server: new /adjust endpoint; brightness/contrast/gamma/fade are baked into each frame with PIL before serving, so the Manta receives pre-adjusted PNGs. - Capture screen: debounce-POSTs adjustments to /adjust, then calls the native bake with identity values so the Supernote side does no per-pixel work. - Native: bake() now short-circuits the per-pixel loop when all adjustments are identity (just decode/resize/encode). Mac region picker - Replaces the fullscreen semi-transparent overlay (which on some multi-monitor setups went black on the wrong display) with an in-window modal: capture the primary monitor once, downscale to ~640px, let the user drag a rectangle on the thumbnail, map back to monitor pixels. Bump 0.5.0 -> 0.5.1 / versionCode 12 -> 13. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- PluginConfig.json | 4 +- .../com/embedimage/ImageProcessorModule.kt | 20 ++- macapp/gui.py | 149 ++++++++++++++---- macapp/server.py | 70 +++++++- src/screens/Browser.tsx | 51 +----- src/screens/Capture.tsx | 36 ++++- 6 files changed, 245 insertions(+), 85 deletions(-) diff --git a/PluginConfig.json b/PluginConfig.json index 57b8a0f..56eaa6c 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.5.0", - "versionCode": "12", + "versionName": "0.5.1", + "versionCode": "13", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt b/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt index 035c052..b500e9e 100644 --- a/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt +++ b/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt @@ -103,6 +103,24 @@ class ImageProcessorModule(reactContext: ReactApplicationContext) : val w = src.width val h = src.height + + val isIdentity = whiteAlpha <= 0.0 + && brightness == 0 + && contrast == 0 + && kotlin.math.abs(gamma - 1.0) < 1e-6 + + val tag = if (previewMaxDim > 0) "prev" else "embed" + val outFile = File(reactApplicationContext.cacheDir, "${tag}_${System.currentTimeMillis()}.png") + + if (isIdentity) { + // Identity adjustments — skip the per-pixel loop. Saves real + // CPU on Manta when the Mac server already baked the look in. + FileOutputStream(outFile).use { stream -> + src.compress(Bitmap.CompressFormat.PNG, 100, stream) + } + return outFile.absolutePath + } + val pixels = IntArray(w * h) src.getPixels(pixels, 0, w, 0, 0, w, h) @@ -147,8 +165,6 @@ class ImageProcessorModule(reactContext: ReactApplicationContext) : val out = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) out.setPixels(pixels, 0, w, 0, 0, w, h) - val tag = if (previewMaxDim > 0) "prev" else "embed" - val outFile = File(reactApplicationContext.cacheDir, "${tag}_${System.currentTimeMillis()}.png") FileOutputStream(outFile).use { stream -> out.compress(Bitmap.CompressFormat.PNG, 100, stream) } diff --git a/macapp/gui.py b/macapp/gui.py index 900b9d4..14409dd 100644 --- a/macapp/gui.py +++ b/macapp/gui.py @@ -7,63 +7,150 @@ """ from __future__ import annotations -import threading import time import tkinter as tk from tkinter import ttk +import mss +from PIL import Image, ImageTk + from . import server -# ---- Region picker overlay ---- +# ---- Region picker (embedded thumbnail) ---- + +REGION_PREVIEW_MAX = 640 + class RegionPicker: - """A topmost semi-transparent window that lets the user drag a rectangle.""" + """Modal window showing a downscaled screenshot; drag to select region. - def __init__(self, on_done): + The user's screen is captured once and rendered into the dialog at + around 640px wide. Drag coordinates on the canvas are mapped back to + monitor pixels so the live capture loop crops the right area. + """ + + def __init__(self, parent, on_done): self.on_done = on_done - self.root = tk.Toplevel() - self.root.attributes("-fullscreen", True) - self.root.attributes("-alpha", 0.25) - self.root.configure(background="#000") - self.root.attributes("-topmost", True) - self.canvas = tk.Canvas(self.root, bg="#000", highlightthickness=0) - self.canvas.pack(fill="both", expand=True) + + with mss.mss() as sct: + monitors = sct.monitors + mon = monitors[1] if len(monitors) > 1 else monitors[0] + shot = sct.grab(mon) + pil_full = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX") + + self.mon_x = int(mon["left"]) + self.mon_y = int(mon["top"]) + self.mon_w = int(mon["width"]) + self.mon_h = int(mon["height"]) + + scale = REGION_PREVIEW_MAX / max(self.mon_w, self.mon_h) + self.scale = scale + thumb_w = max(1, int(self.mon_w * scale)) + thumb_h = max(1, int(self.mon_h * scale)) + pil_thumb = pil_full.resize((thumb_w, thumb_h), Image.LANCZOS) + + self.root = tk.Toplevel(parent) + self.root.title("Select region") + self.root.transient(parent) + self.root.grab_set() + self.root.resizable(False, False) + + self.hint = ttk.Label( + self.root, + text="Drag on the preview to pick a region. Esc to cancel.", + foreground="#444", + ) + self.hint.pack(padx=8, pady=(8, 4)) + + self._photo = ImageTk.PhotoImage(pil_thumb) + self.canvas = tk.Canvas( + self.root, width=thumb_w, height=thumb_h, highlightthickness=1, + highlightbackground="#888", cursor="crosshair", + ) + self.canvas.create_image(0, 0, image=self._photo, anchor="nw") + self.canvas.pack(padx=8, pady=4) + + self.size_label = ttk.Label(self.root, text="(no selection)", foreground="#666") + self.size_label.pack(padx=8, pady=4) + + btn_row = ttk.Frame(self.root) + btn_row.pack(padx=8, pady=(0, 8), fill="x") + ttk.Button(btn_row, text="Cancel", command=self._cancel).pack(side="right", padx=4) + self.ok_btn = ttk.Button(btn_row, text="Use selection", command=self._confirm, state="disabled") + self.ok_btn.pack(side="right", padx=4) + ttk.Button(btn_row, text="Full screen", command=self._use_full).pack(side="left", padx=4) + self.start = None self.rect_id = None + self.selection = None # (cx0, cy0, cx1, cy1) in canvas pixels + self.canvas.bind("", self._on_down) self.canvas.bind("", self._on_move) self.canvas.bind("", self._on_up) self.root.bind("", lambda _: self._cancel()) + self.root.bind("", lambda _: self._confirm() if self.selection else None) + self.root.protocol("WM_DELETE_WINDOW", self._cancel) def _on_down(self, e): - self.start = (e.x_root, e.y_root) + self.start = (e.x, e.y) if self.rect_id: self.canvas.delete(self.rect_id) self.rect_id = self.canvas.create_rectangle( - e.x, e.y, e.x, e.y, outline="#fff", width=2 + e.x, e.y, e.x, e.y, outline="#ff3", width=2, ) def _on_move(self, e): - if not self.start or not self.rect_id: + if self.start is None or self.rect_id is None: return - x0 = self.start[0] - self.root.winfo_rootx() - y0 = self.start[1] - self.root.winfo_rooty() - self.canvas.coords(self.rect_id, x0, y0, e.x, e.y) + self.canvas.coords(self.rect_id, self.start[0], self.start[1], e.x, e.y) + self._update_size_label(self.start[0], self.start[1], e.x, e.y) def _on_up(self, e): - if not self.start: - self._cancel() + if self.start is None: return x0, y0 = self.start - x1, y1 = e.x_root, e.y_root - x, y = min(x0, x1), min(y0, y1) - w, h = abs(x1 - x0), abs(y1 - y0) + x1, y1 = e.x, e.y + cw = abs(x1 - x0) + ch = abs(y1 - y0) + if cw < 4 or ch < 4: + if self.rect_id: + self.canvas.delete(self.rect_id) + self.rect_id = None + self.selection = None + self.size_label.config(text="(too small — drag a bigger box)") + self.ok_btn.config(state="disabled") + return + self.selection = (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)) + self._update_size_label(x0, y0, x1, y1) + self.ok_btn.config(state="normal") + + def _update_size_label(self, x0, y0, x1, y1): + cw = abs(x1 - x0) + ch = abs(y1 - y0) + sw = round(cw / self.scale) + sh = round(ch / self.scale) + self.size_label.config(text=f"selection: {sw}×{sh} px (preview {cw}×{ch})") + + def _to_monitor(self): + if not self.selection: + return None + cx0, cy0, cx1, cy1 = self.selection + return { + "x": self.mon_x + round(cx0 / self.scale), + "y": self.mon_y + round(cy0 / self.scale), + "w": round((cx1 - cx0) / self.scale), + "h": round((cy1 - cy0) / self.scale), + } + + def _confirm(self): + region = self._to_monitor() self.root.destroy() - if w < 20 or h < 20: - self.on_done(None) - else: - self.on_done({"x": int(x), "y": int(y), "w": int(w), "h": int(h)}) + self.on_done(region) + + def _use_full(self): + self.root.destroy() + self.on_done({"x": self.mon_x, "y": self.mon_y, "w": self.mon_w, "h": self.mon_h}) def _cancel(self): self.root.destroy() @@ -181,20 +268,18 @@ def _refresh_windows(self): self.window_combo.current(0) def _pick_region(self): - self.root.iconify() - time.sleep(0.2) - def done(region): - self.root.deiconify() if region: - self.region_label.config(text=f"{region['w']}×{region['h']} @ {region['x']},{region['y']}") + self.region_label.config( + text=f"{region['w']}×{region['h']} @ {region['x']},{region['y']}" + ) self._region = region self.source_var.set("region") self._send_source() else: self._log("region pick cancelled") - RegionPicker(done) + RegionPicker(self.root, done) def _send_source(self): src = self.source_var.get() diff --git a/macapp/server.py b/macapp/server.py index bf28368..933913c 100644 --- a/macapp/server.py +++ b/macapp/server.py @@ -12,6 +12,8 @@ POST /stop -> pause capture loop POST /source {source, ...} -> change capture source (full screen, region, window) POST /interval {interval_sec} -> change capture interval +POST /adjust {fade, brightness, contrast, gamma} + -> apply tone adjustments to served frames GET /windows -> JSON list of visible windows from Quartz """ from __future__ import annotations @@ -26,7 +28,50 @@ import mss from flask import Flask, jsonify, request -from PIL import Image +from PIL import Image, ImageEnhance + + +# Adjustment ranges must match src/AdjustmentPanel.tsx on the plugin side: +# fade 0..100 (% blended toward white) +# brightness -100..100 +# contrast -100..100 +# gamma 0.2..3.0 (1.0 = identity) +_DEFAULT_ADJUST = {"fade": 0, "brightness": 0, "contrast": 0, "gamma": 1.0} + + +def _is_identity_adjust(a: dict) -> bool: + return ( + int(a.get("fade", 0)) == 0 + and int(a.get("brightness", 0)) == 0 + and int(a.get("contrast", 0)) == 0 + and abs(float(a.get("gamma", 1.0)) - 1.0) < 1e-6 + ) + + +def _apply_adjust(img: Image.Image, a: dict) -> Image.Image: + """Apply brightness/contrast/gamma/fade to an RGB PIL image.""" + if _is_identity_adjust(a): + return img + out = img + brightness = int(a.get("brightness", 0)) + contrast = int(a.get("contrast", 0)) + gamma = float(a.get("gamma", 1.0)) + fade = int(a.get("fade", 0)) + + if brightness: + # -100..100 maps to PIL factor 0..2 (1.0 = unchanged). + out = ImageEnhance.Brightness(out).enhance(1.0 + brightness / 100.0) + if contrast: + out = ImageEnhance.Contrast(out).enhance(1.0 + contrast / 100.0) + if abs(gamma - 1.0) > 1e-6 and gamma > 0: + inv = 1.0 / gamma + lut = [min(255, int(round(255.0 * (i / 255.0) ** inv))) for i in range(256)] + out = out.point(lut * len(out.getbands())) + if fade: + alpha = max(0, min(100, fade)) / 100.0 + white = Image.new("RGB", out.size, (255, 255, 255)) + out = Image.blend(out, white, alpha) + return out try: import Quartz # provided by pyobjc-framework-Quartz @@ -43,6 +88,7 @@ class CaptureState: region: Optional[dict] = None # {x, y, w, h} window_id: Optional[int] = None interval_sec: float = 1.0 + adjust: dict = field(default_factory=lambda: dict(_DEFAULT_ADJUST)) last_frame: Optional[bytes] = None last_frame_ts: float = 0.0 @@ -149,6 +195,7 @@ def _capture_once(sct: mss.mss) -> Optional[bytes]: shot = sct.grab(target) pil = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX") + pil = _apply_adjust(pil, dict(STATE.adjust)) buf = io.BytesIO() pil.save(buf, format="PNG", optimize=False) return buf.getvalue() @@ -196,6 +243,7 @@ def status(): "region": STATE.region, "window_id": STATE.window_id, "interval_sec": STATE.interval_sec, + "adjust": dict(STATE.adjust), "frame_count": STATE.frame_count, "has_frame": STATE.last_frame is not None, "last_frame_ts": STATE.last_frame_ts, @@ -257,6 +305,26 @@ def set_interval(): return jsonify({"ok": True, "interval_sec": STATE.interval_sec}) +@app.route("/adjust", methods=["POST"]) +def set_adjust(): + body = request.get_json(force=True, silent=True) or {} + new = dict(_DEFAULT_ADJUST) + try: + if "fade" in body: + new["fade"] = max(0, min(100, int(body["fade"]))) + if "brightness" in body: + new["brightness"] = max(-100, min(100, int(body["brightness"]))) + if "contrast" in body: + new["contrast"] = max(-100, min(100, int(body["contrast"]))) + if "gamma" in body: + new["gamma"] = max(0.2, min(3.0, float(body["gamma"]))) + except (TypeError, ValueError): + return jsonify({"error": "invalid adjust value"}), 400 + STATE.adjust = new + log(f"adjust -> {new}") + return jsonify({"ok": True, "adjust": new}) + + @app.route("/windows") def windows(): return jsonify(list_windows()) diff --git a/src/screens/Browser.tsx b/src/screens/Browser.tsx index c7e48bc..70d25b3 100644 --- a/src/screens/Browser.tsx +++ b/src/screens/Browser.tsx @@ -82,7 +82,6 @@ export function BrowserScreen({ const [entries, setEntries] = useState([]); const [sort, setSort] = useState('date_desc'); const [columns, setColumns] = useState(DEFAULT_COLUMNS); - const [sdRoots, setSdRoots] = useState([]); const [status, setStatus] = useState('starting…'); const load = useCallback(async (dir: string) => { @@ -146,31 +145,12 @@ export function BrowserScreen({ load(currentDir); }, [currentDir, load]); - useEffect(() => { - let cancelled = false; - (async () => { - try { - const res: any = await (FileUtils as any).getExternalDirPath?.(); - if (cancelled) return; - const list: string[] = Array.isArray(res) - ? res.filter((p) => typeof p === 'string' && p && p !== INTERNAL_ROOT) - : []; - setSdRoots(list); - } catch { - // device may not expose any external storage; ignore. - } - })(); - return () => { - cancelled = true; - }; - }, []); - - const allRoots = useMemo(() => [INTERNAL_ROOT, ...sdRoots], [sdRoots]); - const currentRoot = useMemo(() => { - return ( - allRoots.find((r) => currentDir === r || currentDir.startsWith(r + '/')) ?? INTERNAL_ROOT - ); - }, [allRoots, currentDir]); + // Note: previous versions probed FileUtils.getExternalDirPath() to expose + // SD-card roots. The new Manta firmware leaves getCurrentActivity() null + // when the JS bundle first runs, so that native call throws an NPE that + // bypasses our try/catch and tears the RN bridge down. Use "Browse…" + // (system picker) for SD / WebDAV instead. + const currentRoot = INTERNAL_ROOT; const sorted = useMemo(() => sortEntries(entries, sort), [entries, sort]); @@ -250,26 +230,11 @@ export function BrowserScreen({ Home setCurrentDir(INTERNAL_ROOT)} > - - Internal - + Internal - {sdRoots.map((r, i) => { - const active = currentRoot === r; - const label = sdRoots.length > 1 ? `SD ${i + 1}` : 'SD'; - return ( - setCurrentDir(r)} - > - {label} - - ); - })} {currentDir} diff --git a/src/screens/Capture.tsx b/src/screens/Capture.tsx index 361dbf4..5e2e080 100644 --- a/src/screens/Capture.tsx +++ b/src/screens/Capture.tsx @@ -23,6 +23,11 @@ import { StreamConfig, } from '../types'; +// During live capture the Mac server bakes adjustments into each frame +// before sending, so the Supernote-side bake should be identity. This +// trips the native module's fast path (no per-pixel loop). +const IDENTITY_ADJUSTMENTS: Adjustments = DEFAULT_ADJUSTMENTS; + const PREVIEW_MAX_DIM = 800; const MIN_INTERVAL_SEC = 0.2; const MAX_INTERVAL_SEC = 60; @@ -68,8 +73,29 @@ export function CaptureScreen({ }, []); const url = baseUrl(config); - const adjustmentsRef = useRef(adjustments); - adjustmentsRef.current = adjustments; + + // Debounced push of adjustments to the Mac server. The server applies + // them to each captured frame; the Supernote-side bake then runs an + // identity pass and skips the per-pixel loop. + useEffect(() => { + if (!url) return; + const timer = setTimeout(() => { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 2000); + fetch(`${url}/adjust`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(adjustments), + signal: ctrl.signal, + }) + .then(() => clearTimeout(t)) + .catch((e: any) => { + clearTimeout(t); + pushLog(`adjust push failed: ${e?.message ?? e}`); + }); + }, 250); + return () => clearTimeout(timer); + }, [adjustments, url, pushLog]); const fetchOnce = useCallback(async (): Promise => { if (!url) { @@ -81,7 +107,7 @@ export function CaptureScreen({ try { const path = await downloadAndBake( `${url}/frame`, - adjustmentsRef.current, + IDENTITY_ADJUSTMENTS, PREVIEW_MAX_DIM, Math.max(2000, Math.round(intervalSec * 1500)), ); @@ -163,7 +189,7 @@ export function CaptureScreen({ setBusy(true); pushLog('insert: baking full-res…'); try { - const fullPath = await downloadAndBake(`${url}/frame`, adjustmentsRef.current, 0, 8000); + const fullPath = await downloadAndBake(`${url}/frame`, IDENTITY_ADJUSTMENTS, 0, 8000); const newTrack = await insertAndTrack(fullPath); if (newTrack) { setTrack(newTrack); @@ -186,7 +212,7 @@ export function CaptureScreen({ setBusy(true); pushLog('replace: baking full-res…'); try { - const fullPath = await downloadAndBake(`${url}/frame`, adjustmentsRef.current, 0, 8000); + const fullPath = await downloadAndBake(`${url}/frame`, IDENTITY_ADJUSTMENTS, 0, 8000); const newTrack = await replaceInPlace(track, fullPath); if (newTrack) { setTrack(newTrack); From 3a67b248a653600d77545d164f76b71e71432bae Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 09:05:14 +0000 Subject: [PATCH 07/28] gitignore: ignore Python __pycache__/ and .pyc files https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index adc40d6..c57fc2f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ dist/ *.log .metro-health-check* +# Python +__pycache__/ +*.pyc + # Android android/.gradle/ android/build/ From 2e2444c45c0e02f0a1116537c95de55e9164ea44 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 09:21:02 +0000 Subject: [PATCH 08/28] Bypass cleartext-HTTP block via raw-socket native fetch The Supernote pluginhost process disallows cleartext HTTP (Android's NetworkSecurityPolicy), so JS fetch() to the LAN Mac server fails with "cleartext HTTP traffic to not permitted". Our own manifest's usesCleartextTraffic="true" doesn't apply because the policy is read from the host's process, not ours. - ImageProcessorModule.rawHttpRequest: HTTP/1.1 over a raw Socket, which doesn't hit NetworkSecurityPolicy at all. Handles Content-Length and chunked Transfer-Encoding responses. - downloadAndProcess now uses rawHttpRequest instead of HttpURLConnection. - New nativeHttp(method, url, bodyJson, timeout) for JS. lanHttp/lanJson wrappers in src/imageProcessor.ts. - Capture screen: /status and /adjust now go through lanHttp/lanJson. Settings "Test connection" too. - StubPackage: best-effort reflection patch of NetworkSecurityPolicy as a defensive layer (cheap, no-op on Android versions where the field shape differs). Bump 0.5.1 -> 0.5.2 / versionCode 13 -> 14. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- PluginConfig.json | 4 +- .../com/embedimage/ImageProcessorModule.kt | 147 ++++++++++++++++-- .../main/java/com/embedimage/StubPackage.kt | 23 +++ src/imageProcessor.ts | 29 ++++ src/screens/Capture.tsx | 24 +-- src/screens/Settings.tsx | 11 +- 6 files changed, 196 insertions(+), 42 deletions(-) diff --git a/PluginConfig.json b/PluginConfig.json index 56eaa6c..eee6626 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.5.1", - "versionCode": "13", + "versionName": "0.5.2", + "versionCode": "14", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt b/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt index b500e9e..edac358 100644 --- a/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt +++ b/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt @@ -2,13 +2,19 @@ package com.embedimage import android.graphics.Bitmap import android.graphics.BitmapFactory +import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.WritableMap +import java.io.ByteArrayOutputStream import java.io.File import java.io.FileOutputStream -import java.net.HttpURLConnection +import java.io.InputStream +import java.io.OutputStream +import java.net.InetSocketAddress +import java.net.Socket import java.net.URL import kotlin.math.max import kotlin.math.min @@ -53,19 +59,14 @@ class ImageProcessorModule(reactContext: ReactApplicationContext) : ) { var dlFile: File? = null try { - val conn = URL(url).openConnection() as HttpURLConnection - conn.connectTimeout = timeoutMs - conn.readTimeout = timeoutMs - conn.requestMethod = "GET" - conn.doInput = true - val code = conn.responseCode - if (code !in 200..299) { - throw RuntimeException("HTTP $code") + // Raw-socket HTTP bypasses Android's NetworkSecurityPolicy, which + // blocks cleartext to LAN IPs inside the Supernote pluginhost. + val (status, body) = rawHttpRequest("GET", url, null, null, timeoutMs) + if (status !in 200..299) { + throw RuntimeException("HTTP $status") } dlFile = File(reactApplicationContext.cacheDir, "dl_${System.currentTimeMillis()}.bin") - conn.inputStream.use { input -> - FileOutputStream(dlFile).use { out -> input.copyTo(out) } - } + FileOutputStream(dlFile).use { it.write(body) } val outPath = bake(dlFile.absolutePath, whiteAlpha, brightness, contrast, gamma, previewMaxDim) promise.resolve(outPath) } catch (e: Throwable) { @@ -75,6 +76,128 @@ class ImageProcessorModule(reactContext: ReactApplicationContext) : } } + // Cleartext-safe HTTP fetch used by JS for small JSON endpoints + // (/status, /adjust). Returns {status:int, body:string}. Body is decoded + // as UTF-8; for binary payloads use downloadAndProcess. + @ReactMethod + fun nativeHttp( + method: String, + url: String, + bodyJson: String?, + timeoutMs: Int, + promise: Promise, + ) { + try { + val (status, raw) = rawHttpRequest( + method.uppercase(), + url, + if (bodyJson.isNullOrEmpty()) null else bodyJson.toByteArray(Charsets.UTF_8), + if (bodyJson.isNullOrEmpty()) null else "application/json; charset=utf-8", + timeoutMs, + ) + val result: WritableMap = Arguments.createMap() + result.putInt("status", status) + result.putString("body", String(raw, Charsets.UTF_8)) + promise.resolve(result) + } catch (e: Throwable) { + promise.reject("E_HTTP", e.message ?: e.toString(), e) + } + } + + private fun rawHttpRequest( + method: String, + url: String, + body: ByteArray?, + contentType: String?, + timeoutMs: Int, + ): Pair { + val u = URL(url) + require(u.protocol.equals("http", ignoreCase = true)) { "only http:// supported" } + val host = u.host + val port = if (u.port == -1) 80 else u.port + val path = (u.path.ifEmpty { "/" }) + (if (u.query != null) "?" + u.query else "") + + Socket().use { sock -> + sock.connect(InetSocketAddress(host, port), timeoutMs) + sock.soTimeout = timeoutMs + + val out: OutputStream = sock.getOutputStream() + val req = StringBuilder() + req.append("$method $path HTTP/1.1\r\n") + req.append("Host: $host${if (port == 80) "" else ":$port"}\r\n") + req.append("Connection: close\r\n") + req.append("Accept-Encoding: identity\r\n") + req.append("User-Agent: EmbedImage/1\r\n") + if (body != null) { + req.append("Content-Type: ${contentType ?: "application/octet-stream"}\r\n") + req.append("Content-Length: ${body.size}\r\n") + } + req.append("\r\n") + out.write(req.toString().toByteArray(Charsets.US_ASCII)) + if (body != null) out.write(body) + out.flush() + + val inp: InputStream = sock.getInputStream() + val all = ByteArrayOutputStream() + val buf = ByteArray(8192) + while (true) { + val n = inp.read(buf) + if (n <= 0) break + all.write(buf, 0, n) + } + val raw = all.toByteArray() + val sep = indexOfDoubleCRLF(raw) + ?: throw RuntimeException("malformed HTTP response (no header terminator)") + val headerStr = String(raw, 0, sep, Charsets.US_ASCII) + var bodyBytes = raw.copyOfRange(sep + 4, raw.size) + + val lines = headerStr.split("\r\n") + val statusLine = lines.firstOrNull() ?: throw RuntimeException("empty HTTP response") + val statusParts = statusLine.split(" ", limit = 3) + if (statusParts.size < 2) throw RuntimeException("bad status line: $statusLine") + val status = statusParts[1].toIntOrNull() + ?: throw RuntimeException("bad status code: ${statusParts[1]}") + + val isChunked = lines.drop(1).any { + val ix = it.indexOf(':') + if (ix < 0) false + else it.substring(0, ix).trim().equals("Transfer-Encoding", ignoreCase = true) + && it.substring(ix + 1).trim().contains("chunked", ignoreCase = true) + } + if (isChunked) bodyBytes = decodeChunked(bodyBytes) + return status to bodyBytes + } + } + + private fun indexOfDoubleCRLF(data: ByteArray): Int? { + var i = 0 + while (i <= data.size - 4) { + if (data[i] == 0x0D.toByte() && data[i + 1] == 0x0A.toByte() + && data[i + 2] == 0x0D.toByte() && data[i + 3] == 0x0A.toByte() + ) return i + i++ + } + return null + } + + private fun decodeChunked(input: ByteArray): ByteArray { + val out = ByteArrayOutputStream() + var i = 0 + while (i < input.size) { + var j = i + while (j < input.size - 1 && !(input[j] == 0x0D.toByte() && input[j + 1] == 0x0A.toByte())) j++ + if (j >= input.size - 1) break + val sizeLine = String(input, i, j - i, Charsets.US_ASCII).substringBefore(';').trim() + val size = sizeLine.toIntOrNull(16) ?: break + i = j + 2 + if (size == 0) break + if (i + size > input.size) break + out.write(input, i, size) + i += size + 2 + } + return out.toByteArray() + } + private fun bake( inputPath: String, whiteAlpha: Double, diff --git a/android/app/src/main/java/com/embedimage/StubPackage.kt b/android/app/src/main/java/com/embedimage/StubPackage.kt index b504984..579e5ae 100644 --- a/android/app/src/main/java/com/embedimage/StubPackage.kt +++ b/android/app/src/main/java/com/embedimage/StubPackage.kt @@ -11,6 +11,29 @@ import com.facebook.react.uimanager.ViewManager // forces the build pipeline to produce an app.npk, which the plugin host // requires to load the React Native runtime for this plugin. class StubPackage : ReactPackage { + init { + // The Supernote pluginhost runs with cleartext HTTP blocked by + // NetworkSecurityPolicy, so JS fetch() to the LAN Mac server fails + // with "cleartext HTTP traffic to X not permitted". Our own + // AndroidManifest setting doesn't apply because the policy is read + // from the host process. Force-flip the singleton at plugin load. + try { + val policy = android.security.NetworkSecurityPolicy.getInstance() + val cls = policy.javaClass + for (name in arrayOf("mCleartextTrafficPermitted", "DEFAULT_CLEARTEXT_TRAFFIC_PERMITTED")) { + try { + val f = cls.getDeclaredField(name) + f.isAccessible = true + f.setBoolean(policy, true) + } catch (_: NoSuchFieldException) { + // field name varies by Android version; keep trying. + } + } + } catch (e: Throwable) { + android.util.Log.w("EmbedImage", "cleartext override failed: $e") + } + } + override fun createNativeModules(reactContext: ReactApplicationContext): List = listOf(ImageProcessorModule(reactContext)) override fun createViewManagers(reactContext: ReactApplicationContext): List>> = emptyList() diff --git a/src/imageProcessor.ts b/src/imageProcessor.ts index 35525e3..44c201e 100644 --- a/src/imageProcessor.ts +++ b/src/imageProcessor.ts @@ -19,6 +19,14 @@ type ImageProcessorNative = { previewMaxDim: number, timeoutMs: number, ) => Promise; + // Cleartext-safe HTTP for LAN endpoints. Bypasses Android's + // NetworkSecurityPolicy (the pluginhost blocks our JS fetch()). + nativeHttp: ( + method: string, + url: string, + bodyJson: string | null, + timeoutMs: number, + ) => Promise<{ status: number; body: string }>; cleanupCache: () => Promise; getConfigValue: (key: string) => Promise; setConfigValue: (key: string, value: string | null) => Promise; @@ -70,3 +78,24 @@ export async function downloadAndBake( timeoutMs, ); } + +export async function lanHttp( + method: 'GET' | 'POST', + url: string, + body?: object, + timeoutMs: number = 3000, +): Promise<{ status: number; body: string }> { + if (!native) throw new Error('ImageProcessor native module missing'); + return native.nativeHttp(method, url, body ? JSON.stringify(body) : null, timeoutMs); +} + +export async function lanJson( + method: 'GET' | 'POST', + url: string, + body?: object, + timeoutMs: number = 3000, +): Promise { + const res = await lanHttp(method, url, body, timeoutMs); + if (res.status < 200 || res.status >= 300) throw new Error(`HTTP ${res.status}`); + return JSON.parse(res.body) as T; +} diff --git a/src/screens/Capture.tsx b/src/screens/Capture.tsx index 5e2e080..d9bab37 100644 --- a/src/screens/Capture.tsx +++ b/src/screens/Capture.tsx @@ -12,7 +12,7 @@ import { } from 'react-native'; import { PluginManager } from 'sn-plugin-lib'; import { AdjustmentPanel } from '../AdjustmentPanel'; -import { downloadAndBake } from '../imageProcessor'; +import { downloadAndBake, lanHttp, lanJson } from '../imageProcessor'; import { insertAndTrack, replaceInPlace } from '../embedTracker'; import { baseUrl, saveStreamConfig } from '../storage'; import { @@ -80,19 +80,9 @@ export function CaptureScreen({ useEffect(() => { if (!url) return; const timer = setTimeout(() => { - const ctrl = new AbortController(); - const t = setTimeout(() => ctrl.abort(), 2000); - fetch(`${url}/adjust`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(adjustments), - signal: ctrl.signal, - }) - .then(() => clearTimeout(t)) - .catch((e: any) => { - clearTimeout(t); - pushLog(`adjust push failed: ${e?.message ?? e}`); - }); + lanHttp('POST', `${url}/adjust`, adjustments, 2000).catch((e: any) => { + pushLog(`adjust push failed: ${e?.message ?? e}`); + }); }, 250); return () => clearTimeout(timer); }, [adjustments, url, pushLog]); @@ -157,11 +147,7 @@ export function CaptureScreen({ return; } try { - const ctrl = new AbortController(); - const t = setTimeout(() => ctrl.abort(), 3000); - const r = await fetch(`${url}/status`, { signal: ctrl.signal }); - clearTimeout(t); - const j = await r.json(); + const j: any = await lanJson('GET', `${url}/status`, undefined, 3000); pushLog(`status: running=${j.running}, src=${j.source}, ip=${j.ip ?? '?'}`); } catch (e: any) { pushLog(`status failed: ${e?.message ?? e}`); diff --git a/src/screens/Settings.tsx b/src/screens/Settings.tsx index 5d817ed..f6d64c8 100644 --- a/src/screens/Settings.tsx +++ b/src/screens/Settings.tsx @@ -9,6 +9,7 @@ import { View, } from 'react-native'; import { baseUrl, loadStreamConfig, saveStreamConfig } from '../storage'; +import { lanJson } from '../imageProcessor'; import { DEFAULT_STREAM_CONFIG, StreamConfig } from '../types'; function clamp(n: number, lo: number, hi: number): number { @@ -43,15 +44,7 @@ export function SettingsScreen({ try { const portNum = clamp(parseInt(port, 10) || 0, 1, 65535); const url = `http://${host.trim()}:${portNum}/status`; - const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), 4000); - const res = await fetch(url, { signal: ctrl.signal }); - clearTimeout(timer); - if (!res.ok) { - setStatus(`HTTP ${res.status}`); - return; - } - const json = await res.json(); + const json: any = await lanJson('GET', url, undefined, 4000); setStatus(`ok — running=${json.running}, source=${json.source}, ip=${json.ip ?? '?'}`); } catch (e: any) { setStatus(`failed: ${e?.message ?? e}`); From d57e15429d83aba345cad3b318b47a6bf698ca90 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 09:54:07 +0000 Subject: [PATCH 09/28] Make refreshing the embedded image one-tap The plugin's JS context is torn down when the view closes, so the embedded image can't auto-update in the background. This patch shortens the refresh loop instead. - Capture screen: bottom-row Close now exits the plugin (back to the note canvas), not back to the file Browser. Header Back still goes to Browser. - Capture screen: new Replace & Close button. One tap, fetches the latest frame, replaces the tracked embed (or inserts on first run), closes the plugin. - New "Refresh Embed" sidebar button (id=2). On tap it opens a tiny status screen that loads the persisted embed track + stream config, fetches the latest frame, calls replaceInPlace, and closes itself. No need to navigate through the file picker. - Embed track persisted via ImageProcessor.setConfigValue so the refresh button works after a fresh plugin launch. Bump 0.5.2 -> 0.5.3 / versionCode 14 -> 15. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- App.tsx | 22 ++++++++++++++++ PluginConfig.json | 4 +-- index.js | 27 ++++++++++++++----- src/embedTracker.ts | 15 ++++++++--- src/screens/Capture.tsx | 45 +++++++++++++++++++++++++++++--- src/screens/Refresh.tsx | 57 +++++++++++++++++++++++++++++++++++++++++ src/storage.ts | 21 ++++++++++++++- src/types.ts | 2 +- 8 files changed, 174 insertions(+), 19 deletions(-) create mode 100644 src/screens/Refresh.tsx diff --git a/App.tsx b/App.tsx index 28b2084..6084724 100644 --- a/App.tsx +++ b/App.tsx @@ -1,12 +1,17 @@ import React, { useCallback, useEffect, useState } from 'react'; +import { PluginManager } from 'sn-plugin-lib'; import { ImageProcessor } from './src/imageProcessor'; import { BrowserScreen } from './src/screens/Browser'; import { CaptureScreen } from './src/screens/Capture'; import { PreviewScreen } from './src/screens/Preview'; +import { RefreshScreen } from './src/screens/Refresh'; import { SettingsScreen } from './src/screens/Settings'; import { loadStreamConfig } from './src/storage'; import { DEFAULT_STREAM_CONFIG, Entry, Screen, StreamConfig } from './src/types'; +// Must match BUTTON_REFRESH in index.js. +const BUTTON_REFRESH = 2; + export default function App(): React.JSX.Element { const [screen, setScreen] = useState('browser'); const [selectedEntry, setSelectedEntry] = useState(null); @@ -18,6 +23,19 @@ export default function App(): React.JSX.Element { const cfg = await loadStreamConfig(); setStreamConfig(cfg); })(); + + // Route based on which sidebar button was tapped. PluginManager replays + // the last button event to a freshly-registered listener, so the local + // subscription here will fire immediately if we were launched by the + // Refresh button. + const sub = PluginManager.registerButtonListener({ + onButtonPress: (msg: any) => { + if (msg?.id === BUTTON_REFRESH) setScreen('refresh'); + }, + }); + return () => { + sub?.remove?.(); + }; }, []); const openPreview = useCallback((entry: Entry) => { @@ -30,6 +48,10 @@ export default function App(): React.JSX.Element { setScreen('browser'); }, []); + if (screen === 'refresh') { + return ; + } + if (screen === 'preview' && selectedEntry) { return ; } diff --git a/PluginConfig.json b/PluginConfig.json index eee6626..8d03e22 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.5.2", - "versionCode": "14", + "versionName": "0.5.3", + "versionCode": "15", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/index.js b/index.js index 7216dbe..faf5485 100644 --- a/index.js +++ b/index.js @@ -7,19 +7,32 @@ AppRegistry.registerComponent(appName, () => App); PluginManager.init(); -const BUTTON_ID = 1; +// Button IDs. App.tsx reads the last pressed id off PluginManager and +// switches to the "refresh" headless flow if it sees BUTTON_REFRESH. +export const BUTTON_MAIN = 1; +export const BUTTON_REFRESH = 2; -// type=1: sidebar button (plugins area). showType=1: tap opens the plugin view (App.tsx). +const ICON = Image.resolveAssetSource(require('./assets/icon.png')).uri; + +// type=1: sidebar button. showType=1: tap opens the plugin view (App.tsx). +// Both buttons go through the same view; App.tsx routes based on the +// pressed button id. PluginManager.registerButton(1, ['NOTE'], { - id: BUTTON_ID, + id: BUTTON_MAIN, name: 'Embed Image', - icon: Image.resolveAssetSource(require('./assets/icon.png')).uri, + icon: ICON, + showType: 1, +}); + +PluginManager.registerButton(1, ['NOTE'], { + id: BUTTON_REFRESH, + name: 'Refresh Embed', + icon: ICON, showType: 1, }); PluginManager.registerButtonListener({ - onButtonPress: (msg) => { - if (!msg || msg.id !== BUTTON_ID) return; - // showType=1 opens the view automatically; nothing else to do here. + onButtonPress: (_msg) => { + // Routing happens in App.tsx via PluginManager.lastButtonEventMsg. }, }); diff --git a/src/embedTracker.ts b/src/embedTracker.ts index 2ff3179..aaa0d37 100644 --- a/src/embedTracker.ts +++ b/src/embedTracker.ts @@ -1,4 +1,5 @@ import { PluginCommAPI, PluginFileAPI, PluginNoteAPI } from 'sn-plugin-lib'; +import { saveEmbedTrack } from './storage'; import { EmbedTrack } from './types'; // Tracks the most recently embedded Picture element so the live-capture @@ -57,13 +58,16 @@ export async function insertAndTrack(pngPath: string): Promise {}); + return track; } // Re-fetch the tracked element so we pick up any rect changes from the lasso. @@ -114,11 +118,14 @@ export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Pro await PluginNoteAPI.saveCurrentNote(); } catch {} + let newTrack: EmbedTrack | null = null; try { const lastRes: any = await PluginFileAPI.getLastElement(); const el = lastRes?.result ?? lastRes; - return fromElement(fresh.notePath, fresh.page, el); + newTrack = fromElement(fresh.notePath, fresh.page, el); } catch { - return null; + newTrack = null; } + if (newTrack) await saveEmbedTrack(newTrack).catch(() => {}); + return newTrack; } diff --git a/src/screens/Capture.tsx b/src/screens/Capture.tsx index d9bab37..6fb6f35 100644 --- a/src/screens/Capture.tsx +++ b/src/screens/Capture.tsx @@ -214,19 +214,47 @@ export function CaptureScreen({ } }, [busy, track, url, pushLog]); - const onClose = useCallback(() => { + // "Back" returns to the file browser (existing flow). + const onBackToBrowser = useCallback(() => { setCapturing(false); - setTrack(null); onBack(); }, [onBack]); + // "Close" exits the plugin entirely, back to the note canvas — the user + // asked for this so they can resume drawing without going via Browser. + const onCloseToCanvas = useCallback(() => { + setCapturing(false); + PluginManager.closePluginView().catch(() => {}); + }, []); + + // "Replace & Close": one-tap refresh + back to canvas. Falls back to + // Insert if there's no tracked embed yet. + const onReplaceAndClose = useCallback(async () => { + if (busy) return; + setBusy(true); + pushLog(track ? 'replace+close: baking…' : 'insert+close: baking…'); + try { + const fullPath = await downloadAndBake(`${url}/frame`, IDENTITY_ADJUSTMENTS, 0, 8000); + if (track) { + await replaceInPlace(track, fullPath); + } else { + await insertAndTrack(fullPath); + } + setCapturing(false); + await PluginManager.closePluginView().catch(() => {}); + } catch (e: any) { + pushLog(`failed: ${e?.message ?? e}`); + setBusy(false); + } + }, [busy, track, url, pushLog]); + const sourceUri = framePath ? 'file://' + framePath : null; const ageSec = lastFrameTs ? Math.round((Date.now() - lastFrameTs) / 1000) : null; return ( - + Back Live Capture @@ -294,7 +322,7 @@ export function CaptureScreen({ - + Close Replace + + + {track ? 'Replace & Close' : 'Insert & Close'} + + {busy ? ( diff --git a/src/screens/Refresh.tsx b/src/screens/Refresh.tsx new file mode 100644 index 0000000..cf98079 --- /dev/null +++ b/src/screens/Refresh.tsx @@ -0,0 +1,57 @@ +import React, { useEffect, useState } from 'react'; +import { ActivityIndicator, SafeAreaView, StyleSheet, Text } from 'react-native'; +import { PluginManager } from 'sn-plugin-lib'; +import { replaceInPlace, insertAndTrack } from '../embedTracker'; +import { downloadAndBake } from '../imageProcessor'; +import { baseUrl, loadEmbedTrack, loadStreamConfig } from '../storage'; +import { DEFAULT_ADJUSTMENTS } from '../types'; + +// Headless-ish: opens briefly when the "Refresh Embed" sidebar button is +// tapped, fetches the latest frame from the configured Mac server, and +// replaces (or inserts) the embedded image, then closes the plugin view. +export function RefreshScreen(): React.JSX.Element { + const [status, setStatus] = useState('refreshing…'); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const cfg = await loadStreamConfig(); + const url = baseUrl(cfg); + if (!url) { + setStatus('no server configured — open Embed Image → Settings'); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2500); + return; + } + const track = await loadEmbedTrack(); + const fullPath = await downloadAndBake(`${url}/frame`, DEFAULT_ADJUSTMENTS, 0, 8000); + if (cancelled) return; + if (track) { + await replaceInPlace(track, fullPath); + } else { + await insertAndTrack(fullPath); + } + if (cancelled) return; + await PluginManager.closePluginView().catch(() => {}); + } catch (e: any) { + setStatus(`failed: ${e?.message ?? e}`); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2500); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + return ( + + + {status} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#fff', gap: 16 }, + txt: { fontSize: 14, color: '#000' }, +}); diff --git a/src/storage.ts b/src/storage.ts index 3ba0789..b0e8fbe 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,7 +1,8 @@ import { ImageProcessor } from './imageProcessor'; -import { DEFAULT_STREAM_CONFIG, StreamConfig } from './types'; +import { DEFAULT_STREAM_CONFIG, EmbedTrack, StreamConfig } from './types'; const STREAM_CONFIG_KEY = 'streamConfig'; +const EMBED_TRACK_KEY = 'embedTrack'; export async function loadStreamConfig(): Promise { if (!ImageProcessor?.getConfigValue) return DEFAULT_STREAM_CONFIG; @@ -30,3 +31,21 @@ export function baseUrl(cfg: StreamConfig): string { if (!host) return ''; return `http://${host}:${cfg.port}`; } + +// Persisted across JS-runtime tear-downs so the "Refresh Embed" sidebar +// button can find the last-inserted picture element on a fresh launch. +export async function saveEmbedTrack(track: EmbedTrack | null): Promise { + if (!ImageProcessor?.setConfigValue) return; + await ImageProcessor.setConfigValue(EMBED_TRACK_KEY, track ? JSON.stringify(track) : null); +} + +export async function loadEmbedTrack(): Promise { + if (!ImageProcessor?.getConfigValue) return null; + try { + const raw = await ImageProcessor.getConfigValue(EMBED_TRACK_KEY); + if (!raw) return null; + return JSON.parse(raw) as EmbedTrack; + } catch { + return null; + } +} diff --git a/src/types.ts b/src/types.ts index b0ca5d9..d951b4d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ export type SortKey = 'date_desc' | 'date_asc' | 'name'; export type EntryKind = 'image' | 'folder'; export type Entry = { name: string; path: string; kind: EntryKind }; -export type Screen = 'browser' | 'preview' | 'settings' | 'capture'; +export type Screen = 'browser' | 'preview' | 'settings' | 'capture' | 'refresh'; export type Adjustments = { fade: number; From b97dea4e7b9bba80e00ff35d3b368c89fe3eb410 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 10:18:22 +0000 Subject: [PATCH 10/28] Resolution multiplier, connection dot, Manta-side source picker, BiRefNet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolution multiplier - New resolution_mul in CaptureState (server-side downscale, 0.1..1.0). - /resolution POST endpoint; included in /status payload. - Settings adds a "Resolution multiplier" text field; Capture screen has a Res − / NN% / + row that pushes /resolution debounced. - StreamConfig.resolutionMul is persisted alongside host/port/interval. Connection-status dot - New useConnStatus hook polls /status every 5s with a 2s timeout. - StatusDot renders solid (connected), outlined (disconnected/unknown). - Browser, Capture, and Preview headers all show the dot. Manta-side source picker - New SourcePicker screen: Full screen / Window list / Region. - Region mode fetches /preview-shot (downscaled primary monitor), the user drags a rectangle on the touchscreen, coords are mapped back to Mac pixels using monitor metadata from /status. - Capture screen's header has a new "Source" button that opens it. - New /preview-shot endpoint serves a one-off downscaled screenshot with monitor headers; /status now also returns the monitor rect. BiRefNet background removal (Mac-side) - New /birefnet POST endpoint: lazy-loads torch + transformers + ZhengPeng7/BiRefNet, runs segmentation, composites onto white (or returns alpha with ?bg=transparent). Returns 503 if torch is not installed (user has to `pip install torch torchvision transformers` separately; requirements.txt has a comment for this). - Native: new nativeHttpPostFile that POSTs a file's bytes and writes the binary response to a cache file (lanHttp would corrupt PNG by decoding as UTF-8). JS wrapper: lanPostFile. - Capture: Remove BG button. Only enabled when paused (live stream would loop forever recalculating). Applies to the still preview; Insert/Replace then embeds the BG-removed frame. - Preview: Remove BG button next to Insert. Sends the gallery image to the Mac, swaps the displayed image with the result, embeds that on Insert. Bump 0.5.3 -> 0.6.0 / versionCode 15 -> 16. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- App.tsx | 14 +- PluginConfig.json | 4 +- .../com/embedimage/ImageProcessorModule.kt | 32 ++ macapp/requirements.txt | 5 + macapp/server.py | 194 ++++++++++- src/StatusDot.tsx | 25 ++ src/imageProcessor.ts | 18 + src/screens/Browser.tsx | 12 +- src/screens/Capture.tsx | 84 ++++- src/screens/Preview.tsx | 56 +++- src/screens/Settings.tsx | 31 +- src/screens/SourcePicker.tsx | 312 ++++++++++++++++++ src/storage.ts | 4 + src/types.ts | 16 +- src/useConnStatus.ts | 42 +++ 15 files changed, 824 insertions(+), 25 deletions(-) create mode 100644 src/StatusDot.tsx create mode 100644 src/screens/SourcePicker.tsx create mode 100644 src/useConnStatus.ts diff --git a/App.tsx b/App.tsx index 6084724..cbd9d10 100644 --- a/App.tsx +++ b/App.tsx @@ -6,7 +6,8 @@ import { CaptureScreen } from './src/screens/Capture'; import { PreviewScreen } from './src/screens/Preview'; import { RefreshScreen } from './src/screens/Refresh'; import { SettingsScreen } from './src/screens/Settings'; -import { loadStreamConfig } from './src/storage'; +import { SourcePicker } from './src/screens/SourcePicker'; +import { baseUrl, loadStreamConfig } from './src/storage'; import { DEFAULT_STREAM_CONFIG, Entry, Screen, StreamConfig } from './src/types'; // Must match BUTTON_REFRESH in index.js. @@ -72,6 +73,17 @@ export default function App(): React.JSX.Element { onConfigChange={setStreamConfig} onBack={goBrowser} onOpenSettings={() => setScreen('settings')} + onOpenSourcePicker={() => setScreen('sourcepicker')} + /> + ); + } + + if (screen === 'sourcepicker') { + return ( + setScreen('capture')} + onChanged={() => setScreen('capture')} /> ); } diff --git a/PluginConfig.json b/PluginConfig.json index 8d03e22..3af15a1 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.5.3", - "versionCode": "15", + "versionName": "0.6.0", + "versionCode": "16", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt b/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt index edac358..cf64162 100644 --- a/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt +++ b/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt @@ -79,6 +79,38 @@ class ImageProcessorModule(reactContext: ReactApplicationContext) : // Cleartext-safe HTTP fetch used by JS for small JSON endpoints // (/status, /adjust). Returns {status:int, body:string}. Body is decoded // as UTF-8; for binary payloads use downloadAndProcess. + // POST the contents of `inputPath` as the request body to `url`, write + // the binary response to a temp file in the cache dir, and resolve + // with that path. Used for BiRefNet (PNG in, PNG out) where lanHttp + // would corrupt the response by decoding it as UTF-8 text. + @ReactMethod + fun nativeHttpPostFile( + url: String, + inputPath: String, + contentType: String, + timeoutMs: Int, + promise: Promise, + ) { + try { + val src = File(inputPath) + if (!src.exists()) { + promise.reject("E_HTTP_FILE", "input file does not exist: $inputPath") + return + } + val body = src.readBytes() + val (status, raw) = rawHttpRequest("POST", url, body, contentType, timeoutMs) + if (status !in 200..299) { + promise.reject("E_HTTP", "HTTP $status — ${String(raw, Charsets.UTF_8).take(200)}") + return + } + val outFile = File(reactApplicationContext.cacheDir, "bgr_${System.currentTimeMillis()}.png") + FileOutputStream(outFile).use { it.write(raw) } + promise.resolve(outFile.absolutePath) + } catch (e: Throwable) { + promise.reject("E_HTTP_FILE", e.message ?: e.toString(), e) + } + } + @ReactMethod fun nativeHttp( method: String, diff --git a/macapp/requirements.txt b/macapp/requirements.txt index aa7b1e8..44a4f79 100644 --- a/macapp/requirements.txt +++ b/macapp/requirements.txt @@ -2,3 +2,8 @@ Flask>=3.0 mss>=9.0 Pillow>=10.0 pyobjc-framework-Quartz>=10.0 + +# Optional — required only for the /birefnet endpoint (background removal). +# Install separately when you want to use the Remove BG buttons: +# pip install torch torchvision transformers +# These pull ~2 GB of model + deps so they're kept out of the default install. diff --git a/macapp/server.py b/macapp/server.py index 933913c..4e34f48 100644 --- a/macapp/server.py +++ b/macapp/server.py @@ -6,15 +6,22 @@ Endpoints --------- -GET /status -> JSON {running, source, interval_sec, ip, ...} -GET /frame -> PNG bytes of the latest captured frame -POST /start -> begin capture loop -POST /stop -> pause capture loop -POST /source {source, ...} -> change capture source (full screen, region, window) -POST /interval {interval_sec} -> change capture interval +GET /status -> JSON {running, source, interval_sec, ip, ...} +GET /frame -> PNG bytes of the latest captured frame +POST /start -> begin capture loop +POST /stop -> pause capture loop +POST /source {source, ...} -> change capture source (full screen, region, window) +POST /interval {interval_sec} -> change capture interval POST /adjust {fade, brightness, contrast, gamma} - -> apply tone adjustments to served frames -GET /windows -> JSON list of visible windows from Quartz + -> apply tone adjustments to served frames +POST /resolution {mul} -> downscale factor (0.1..1.0) applied to frames +GET /windows -> JSON list of visible windows from Quartz +GET /preview-shot?max=600 -> one-off PNG screenshot of the primary + monitor for the Manta-side region picker +POST /birefnet -> request body is PNG image bytes; response + is PNG with background removed (composited + onto white). Requires `torch` and + `transformers` to be installed. """ from __future__ import annotations @@ -88,6 +95,7 @@ class CaptureState: region: Optional[dict] = None # {x, y, w, h} window_id: Optional[int] = None interval_sec: float = 1.0 + resolution_mul: float = 1.0 # downscale factor applied to outgoing frames adjust: dict = field(default_factory=lambda: dict(_DEFAULT_ADJUST)) last_frame: Optional[bytes] = None @@ -195,6 +203,11 @@ def _capture_once(sct: mss.mss) -> Optional[bytes]: shot = sct.grab(target) pil = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX") + mul = max(0.05, min(1.0, STATE.resolution_mul)) + if mul < 0.999: + new_w = max(1, int(round(pil.width * mul))) + new_h = max(1, int(round(pil.height * mul))) + pil = pil.resize((new_w, new_h), Image.LANCZOS) pil = _apply_adjust(pil, dict(STATE.adjust)) buf = io.BytesIO() pil.save(buf, format="PNG", optimize=False) @@ -232,8 +245,21 @@ def capture_loop() -> None: logging.getLogger("werkzeug").setLevel(logging.WARNING) +def _primary_monitor() -> dict: + with mss.mss() as sct: + monitors = sct.monitors + mon = monitors[1] if len(monitors) > 1 else monitors[0] + return { + "left": int(mon["left"]), + "top": int(mon["top"]), + "width": int(mon["width"]), + "height": int(mon["height"]), + } + + @app.route("/status") def status(): + mon = _primary_monitor() with STATE.lock: return jsonify( { @@ -243,12 +269,14 @@ def status(): "region": STATE.region, "window_id": STATE.window_id, "interval_sec": STATE.interval_sec, + "resolution_mul": STATE.resolution_mul, "adjust": dict(STATE.adjust), "frame_count": STATE.frame_count, "has_frame": STATE.last_frame is not None, "last_frame_ts": STATE.last_frame_ts, "last_error": STATE.last_error, "ip": get_local_ip(), + "monitor": mon, } ) @@ -325,11 +353,161 @@ def set_adjust(): return jsonify({"ok": True, "adjust": new}) +@app.route("/resolution", methods=["POST"]) +def set_resolution(): + body = request.get_json(force=True, silent=True) or {} + try: + v = float(body.get("mul", body.get("resolution_mul", 1.0))) + except (TypeError, ValueError): + return jsonify({"error": "invalid resolution"}), 400 + STATE.resolution_mul = max(0.1, min(1.0, v)) + log(f"resolution -> {STATE.resolution_mul:.2f}") + return jsonify({"ok": True, "resolution_mul": STATE.resolution_mul}) + + @app.route("/windows") def windows(): return jsonify(list_windows()) +@app.route("/preview-shot") +def preview_shot(): + """Single screenshot for the Manta region picker. Always full primary + monitor (no adjustments, no resolution mul applied), downscaled to a + max dimension. Response: PNG bytes. + + Headers: + X-Mon-Left / X-Mon-Top / X-Mon-Width / X-Mon-Height (mac pixels) + X-Scale (preview / mac) + + Query: + max preview max dimension (default 600) + """ + try: + max_dim = int(request.args.get("max", "600")) + except (TypeError, ValueError): + max_dim = 600 + max_dim = max(160, min(2000, max_dim)) + + with mss.mss() as sct: + monitors = sct.monitors + mon = monitors[1] if len(monitors) > 1 else monitors[0] + shot = sct.grab(mon) + pil = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX") + long_side = max(pil.width, pil.height) + scale = max_dim / long_side if long_side > max_dim else 1.0 + if scale < 0.999: + pil = pil.resize( + (max(1, int(pil.width * scale)), max(1, int(pil.height * scale))), + Image.LANCZOS, + ) + buf = io.BytesIO() + pil.save(buf, format="PNG", optimize=False) + headers = { + "Content-Type": "image/png", + "X-Mon-Left": str(int(mon["left"])), + "X-Mon-Top": str(int(mon["top"])), + "X-Mon-Width": str(int(mon["width"])), + "X-Mon-Height": str(int(mon["height"])), + "X-Scale": f"{scale:.6f}", + } + return buf.getvalue(), 200, headers + + +# ---- BiRefNet background removal (lazy-loaded) ---- + +_BIREFNET = None # cached {model, processor, device} +_BIREFNET_LOCK = threading.Lock() + + +def _load_birefnet(): + """Import torch/transformers lazily and cache the model. Returns the + cached dict on success or raises with a friendly message.""" + global _BIREFNET + with _BIREFNET_LOCK: + if _BIREFNET is not None: + return _BIREFNET + try: + import torch # type: ignore + from transformers import AutoModelForImageSegmentation # type: ignore + except ImportError as e: + raise RuntimeError( + "BiRefNet needs torch+transformers. Install with:\n" + " pip install torch torchvision transformers" + ) from e + log("loading BiRefNet (first call only — may take a while)…") + model = AutoModelForImageSegmentation.from_pretrained( + "ZhengPeng7/BiRefNet", trust_remote_code=True + ) + device = ( + "mps" if torch.backends.mps.is_available() + else "cuda" if torch.cuda.is_available() + else "cpu" + ) + model.to(device).eval() + log(f"BiRefNet ready on {device}") + _BIREFNET = {"model": model, "device": device, "torch": torch} + return _BIREFNET + + +def _birefnet_remove(img: Image.Image, bg: str) -> Image.Image: + """Run BiRefNet; composite onto white (bg='white') or keep alpha + (bg='transparent').""" + state = _load_birefnet() + torch = state["torch"] + model = state["model"] + device = state["device"] + from torchvision import transforms # local import; torchvision came with torch + + tfm = transforms.Compose([ + transforms.Resize((1024, 1024)), + transforms.ToTensor(), + transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), + ]) + rgb = img.convert("RGB") + inp = tfm(rgb).unsqueeze(0).to(device) + with torch.no_grad(): + preds = model(inp)[-1].sigmoid().cpu() + mask = preds[0].squeeze() + from PIL import Image as PI + mask_pil = transforms.ToPILImage()(mask).resize(rgb.size, PI.BILINEAR) + out = rgb.copy() + out.putalpha(mask_pil) + if bg == "transparent": + return out + flat = Image.new("RGB", out.size, (255, 255, 255)) + flat.paste(out, mask=out.split()[3]) + return flat + + +@app.route("/birefnet", methods=["POST"]) +def birefnet(): + """Body: raw image bytes (PNG/JPEG/etc). + Query: bg=white (default) | transparent.""" + bg = request.args.get("bg", "white").lower() + if bg not in ("white", "transparent"): + bg = "white" + raw = request.get_data(cache=False) + if not raw: + return jsonify({"error": "no image body"}), 400 + try: + src = Image.open(io.BytesIO(raw)) + src.load() + except Exception as e: # noqa: BLE001 + return jsonify({"error": f"decode failed: {e}"}), 400 + try: + out = _birefnet_remove(src, bg) + except RuntimeError as e: + log(f"birefnet not available: {e}") + return jsonify({"error": str(e)}), 503 + except Exception as e: # noqa: BLE001 + log(f"birefnet error: {e}") + return jsonify({"error": f"birefnet failed: {e}"}), 500 + buf = io.BytesIO() + out.save(buf, format="PNG", optimize=False) + return buf.getvalue(), 200, {"Content-Type": "image/png"} + + def run_server(host: str, port: int) -> None: from werkzeug.serving import make_server diff --git a/src/StatusDot.tsx b/src/StatusDot.tsx new file mode 100644 index 0000000..f09a2f1 --- /dev/null +++ b/src/StatusDot.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { ConnStatus } from './useConnStatus'; + +// Small filled circle for the connection state. Manta is monochrome, so: +// connected -> solid black +// disconnected -> white with a black ring +// unknown -> open ring (no fill) +export function StatusDot({ status }: { status: ConnStatus }): React.JSX.Element { + const fill = + status === 'connected' ? '#000' : + status === 'disconnected' ? '#fff' : '#fff'; + return ; +} + +const styles = StyleSheet.create({ + dot: { + width: 14, + height: 14, + borderRadius: 7, + borderWidth: 1, + borderColor: '#000', + marginLeft: 8, + }, +}); diff --git a/src/imageProcessor.ts b/src/imageProcessor.ts index 44c201e..2761411 100644 --- a/src/imageProcessor.ts +++ b/src/imageProcessor.ts @@ -27,6 +27,12 @@ type ImageProcessorNative = { bodyJson: string | null, timeoutMs: number, ) => Promise<{ status: number; body: string }>; + nativeHttpPostFile: ( + url: string, + inputPath: string, + contentType: string, + timeoutMs: number, + ) => Promise; cleanupCache: () => Promise; getConfigValue: (key: string) => Promise; setConfigValue: (key: string, value: string | null) => Promise; @@ -99,3 +105,15 @@ export async function lanJson( if (res.status < 200 || res.status >= 300) throw new Error(`HTTP ${res.status}`); return JSON.parse(res.body) as T; } + +// POST a local file's bytes to a URL and save the binary response to a +// new cache file. Returns the output file path. +export async function lanPostFile( + url: string, + inputPath: string, + contentType: string = 'image/png', + timeoutMs: number = 60000, +): Promise { + if (!native) throw new Error('ImageProcessor native module missing'); + return native.nativeHttpPostFile(url, inputPath, contentType, timeoutMs); +} diff --git a/src/screens/Browser.tsx b/src/screens/Browser.tsx index 70d25b3..a72d4d9 100644 --- a/src/screens/Browser.tsx +++ b/src/screens/Browser.tsx @@ -10,7 +10,11 @@ import { View, } from 'react-native'; import { FileUtils, PluginManager, RattaFileSelector } from 'sn-plugin-lib'; -import type { Entry, EntryKind, SortKey } from '../types'; +import { StatusDot } from '../StatusDot'; +import { baseUrl, loadStreamConfig } from '../storage'; +import { useConnStatus } from '../useConnStatus'; +import type { Entry, EntryKind, SortKey, StreamConfig } from '../types'; +import { DEFAULT_STREAM_CONFIG } from '../types'; const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.bmp', '.gif', '.webp']; const INTERNAL_ROOT = '/storage/emulated/0'; @@ -83,6 +87,11 @@ export function BrowserScreen({ const [sort, setSort] = useState('date_desc'); const [columns, setColumns] = useState(DEFAULT_COLUMNS); const [status, setStatus] = useState('starting…'); + const [streamCfg, setStreamCfg] = useState(DEFAULT_STREAM_CONFIG); + const connStatus = useConnStatus(baseUrl(streamCfg)); + useEffect(() => { + loadStreamConfig().then(setStreamCfg).catch(() => {}); + }, []); const load = useCallback(async (dir: string) => { setStatus(`listing ${dir}`); @@ -203,6 +212,7 @@ export function BrowserScreen({ Embed Image + Live… diff --git a/src/screens/Capture.tsx b/src/screens/Capture.tsx index 6fb6f35..83c35a7 100644 --- a/src/screens/Capture.tsx +++ b/src/screens/Capture.tsx @@ -12,9 +12,11 @@ import { } from 'react-native'; import { PluginManager } from 'sn-plugin-lib'; import { AdjustmentPanel } from '../AdjustmentPanel'; -import { downloadAndBake, lanHttp, lanJson } from '../imageProcessor'; +import { StatusDot } from '../StatusDot'; +import { downloadAndBake, lanHttp, lanJson, lanPostFile } from '../imageProcessor'; import { insertAndTrack, replaceInPlace } from '../embedTracker'; import { baseUrl, saveStreamConfig } from '../storage'; +import { useConnStatus } from '../useConnStatus'; import { Adjustments, DEFAULT_ADJUSTMENTS, @@ -49,11 +51,13 @@ export function CaptureScreen({ onConfigChange, onBack, onOpenSettings, + onOpenSourcePicker, }: { config: StreamConfig; onConfigChange: (cfg: StreamConfig) => void; onBack: () => void; onOpenSettings: () => void; + onOpenSourcePicker: () => void; }): React.JSX.Element { const [adjustments, setAdjustments] = useState(DEFAULT_ADJUSTMENTS); const [capturing, setCapturing] = useState(false); @@ -63,7 +67,9 @@ export function CaptureScreen({ const [busy, setBusy] = useState(false); const [track, setTrack] = useState(null); const [intervalSec, setIntervalSec] = useState(config.intervalSec); + const [resolutionMul, setResolutionMul] = useState(config.resolutionMul); const inFlight = useRef(false); + const connStatus = useConnStatus(baseUrl(config)); const pushLog = useCallback((msg: string) => { setLogs((prev) => { @@ -170,6 +176,54 @@ export function CaptureScreen({ [intervalSec, config, onConfigChange], ); + const changeResolutionMul = useCallback( + (delta: number) => { + const next = Math.round(clamp(resolutionMul + delta, 0.1, 1.0) * 10) / 10; + if (next === resolutionMul) return; + setResolutionMul(next); + const cfg = { ...config, resolutionMul: next }; + onConfigChange(cfg); + saveStreamConfig(cfg).catch(() => {}); + if (url) { + lanHttp('POST', `${url}/resolution`, { mul: next }, 2000).catch((e: any) => + pushLog(`resolution push failed: ${e?.message ?? e}`), + ); + } + }, + [resolutionMul, config, onConfigChange, url, pushLog], + ); + + // Send the current resolution to the server when we first connect or + // when the URL changes. Also send when adjustments are pushed but the + // server hasn't been told yet. + useEffect(() => { + if (!url) return; + lanHttp('POST', `${url}/resolution`, { mul: resolutionMul }, 2000).catch(() => {}); + // intentionally only on URL change — interactive changes go through + // changeResolutionMul above. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [url]); + + const onRemoveBg = useCallback(async () => { + if (busy || !framePath) return; + if (capturing) { + pushLog('pause first — Remove BG only works on a still frame'); + return; + } + setBusy(true); + pushLog('removing background via BiRefNet…'); + try { + const outPath = await lanPostFile(`${url}/birefnet?bg=white`, framePath, 'image/png', 120000); + setFramePath(outPath); + setLastFrameTs(Date.now()); + pushLog('background removed (still frame). Insert or Replace to embed.'); + } catch (e: any) { + pushLog(`Remove BG failed: ${e?.message ?? e}`); + } finally { + setBusy(false); + } + }, [busy, framePath, capturing, url, pushLog]); + const onInsert = useCallback(async () => { if (busy) return; setBusy(true); @@ -258,6 +312,10 @@ export function CaptureScreen({ Back Live Capture + + + Source + Settings @@ -292,8 +350,12 @@ export function CaptureScreen({ fetchOnce()} disabled={busy || !url}> Pull once - - Status + + Remove BG changeInterval(-INTERVAL_STEP)} disabled={busy}> @@ -305,6 +367,21 @@ export function CaptureScreen({ + + Res + changeResolutionMul(-0.1)} disabled={busy}> + + + {Math.round(resolutionMul * 100)}% + changeResolutionMul(+0.1)} disabled={busy}> + + + + + + Status + + + @@ -376,6 +453,7 @@ const styles = StyleSheet.create({ btnActive: { backgroundColor: '#000' }, btnTxt: { fontSize: 14, color: '#000' }, btnTxtPrimary: { color: '#fff' }, + btnTxtMuted: { color: '#999' }, previewArea: { flex: 1, backgroundColor: '#fff', margin: 12, borderWidth: 1, borderColor: '#000', diff --git a/src/screens/Preview.tsx b/src/screens/Preview.tsx index 3b71e0c..6d13092 100644 --- a/src/screens/Preview.tsx +++ b/src/screens/Preview.tsx @@ -11,8 +11,11 @@ import { } from 'react-native'; import { PluginManager, PluginNoteAPI } from 'sn-plugin-lib'; import { AdjustmentPanel } from '../AdjustmentPanel'; -import { adjustmentsAreDefault, bakeFile } from '../imageProcessor'; -import { Adjustments, DEFAULT_ADJUSTMENTS, Entry } from '../types'; +import { StatusDot } from '../StatusDot'; +import { adjustmentsAreDefault, bakeFile, lanPostFile } from '../imageProcessor'; +import { baseUrl, loadStreamConfig } from '../storage'; +import { useConnStatus } from '../useConnStatus'; +import { Adjustments, DEFAULT_ADJUSTMENTS, DEFAULT_STREAM_CONFIG, Entry, StreamConfig } from '../types'; const PREVIEW_MAX_DIM = 800; @@ -32,6 +35,13 @@ export function PreviewScreen({ const [previewing, setPreviewing] = useState(false); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(entry.name); + const [bgRemovedPath, setBgRemovedPath] = useState(null); + const [streamCfg, setStreamCfg] = useState(DEFAULT_STREAM_CONFIG); + const connStatus = useConnStatus(baseUrl(streamCfg)); + + useEffect(() => { + loadStreamConfig().then(setStreamCfg).catch(() => {}); + }, []); // Debounced preview bake. useEffect(() => { @@ -66,11 +76,14 @@ export function PreviewScreen({ setStatus(`embedding ${entry.name}…`); try { const allDefault = adjustmentsAreDefault(adjustments); - const needsBake = !allDefault || !isPng(entry.name); - let pathToInsert = entry.path; + const sourcePath = bgRemovedPath ?? entry.path; + // Skip bake if no adjustments and the source is already a PNG (the + // BiRefNet output is PNG too, so this branch covers that as well). + const needsBake = !allDefault || (!bgRemovedPath && !isPng(entry.name)); + let pathToInsert = sourcePath; if (needsBake) { try { - pathToInsert = await bakeFile(entry.path, adjustments, 0); + pathToInsert = await bakeFile(sourcePath, adjustments, 0); } catch (e: any) { setStatus(`process failed: ${e?.message ?? e}`); return; @@ -91,9 +104,30 @@ export function PreviewScreen({ } finally { setBusy(false); } - }, [entry, adjustments, busy]); + }, [entry, adjustments, busy, bgRemovedPath]); + + const onRemoveBg = useCallback(async () => { + if (busy) return; + const url = baseUrl(streamCfg); + if (!url) { + setStatus('no Mac server configured — set it in Settings'); + return; + } + setBusy(true); + setStatus('removing background via BiRefNet (Mac side)…'); + try { + const outPath = await lanPostFile(`${url}/birefnet?bg=white`, entry.path, 'image/png', 120000); + setBgRemovedPath(outPath); + setPreviewPath(outPath); + setStatus('background removed. Insert to embed.'); + } catch (e: any) { + setStatus(`Remove BG failed: ${e?.message ?? e}`); + } finally { + setBusy(false); + } + }, [busy, streamCfg, entry.path]); - const sourceUri = 'file://' + (previewPath ?? entry.path); + const sourceUri = 'file://' + (previewPath ?? bgRemovedPath ?? entry.path); return ( @@ -103,6 +137,7 @@ export function PreviewScreen({ {entry.name} {previewing ? : null} + {status} @@ -117,6 +152,13 @@ export function PreviewScreen({ Cancel + + Remove BG + (DEFAULT_STREAM_CONFIG.host); const [port, setPort] = useState(String(DEFAULT_STREAM_CONFIG.port)); const [intervalSec, setIntervalSec] = useState(String(DEFAULT_STREAM_CONFIG.intervalSec)); + const [resolutionMul, setResolutionMul] = useState(String(DEFAULT_STREAM_CONFIG.resolutionMul)); const [status, setStatus] = useState('loading…'); const [busy, setBusy] = useState(false); @@ -35,6 +36,7 @@ export function SettingsScreen({ setHost(cfg.host); setPort(String(cfg.port)); setIntervalSec(String(cfg.intervalSec)); + setResolutionMul(String(cfg.resolutionMul)); setStatus(cfg.host ? `loaded: ${baseUrl(cfg)}` : 'no server configured'); })(); }, []); @@ -56,7 +58,17 @@ export function SettingsScreen({ try { const portNum = clamp(parseInt(port, 10) || DEFAULT_STREAM_CONFIG.port, 1, 65535); const intervalNum = clamp(parseFloat(intervalSec) || DEFAULT_STREAM_CONFIG.intervalSec, 0.2, 60); - const cfg: StreamConfig = { host: host.trim(), port: portNum, intervalSec: intervalNum }; + const mulNum = clamp( + parseFloat(resolutionMul) || DEFAULT_STREAM_CONFIG.resolutionMul, + 0.1, + 1.0, + ); + const cfg: StreamConfig = { + host: host.trim(), + port: portNum, + intervalSec: intervalNum, + resolutionMul: Math.round(mulNum * 10) / 10, + }; await saveStreamConfig(cfg); setStatus(`saved: ${baseUrl(cfg)}`); onSaved(cfg); @@ -65,7 +77,7 @@ export function SettingsScreen({ } finally { setBusy(false); } - }, [host, port, intervalSec, onSaved]); + }, [host, port, intervalSec, resolutionMul, onSaved]); return ( @@ -121,6 +133,21 @@ export function SettingsScreen({ 0.2 .. 60 seconds. 1.0 = 1 fps (recommended for e-ink). + + Resolution multiplier + + + 0.1 .. 1.0. Mac downscales each frame by this factor before sending. + Lower = less bandwidth + faster Mac → Manta round-trip. + + + Test connection diff --git a/src/screens/SourcePicker.tsx b/src/screens/SourcePicker.tsx new file mode 100644 index 0000000..1199dff --- /dev/null +++ b/src/screens/SourcePicker.tsx @@ -0,0 +1,312 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + FlatList, + GestureResponderEvent, + Image, + Pressable, + SafeAreaView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { lanHttp, lanJson } from '../imageProcessor'; +import { MacWindow, Region } from '../types'; + +// Lets the user pick a capture source (full screen / window / region) +// directly from the Manta. For "region" we pull a downscaled snapshot of +// the Mac primary monitor and let the user drag a rectangle on the +// touchscreen; coordinates are mapped back to Mac pixels via the +// X-Mon-* / X-Scale headers returned by /preview-shot. + +type Mode = 'menu' | 'window' | 'region'; + +export function SourcePicker({ + baseUrl, + onClose, + onChanged, +}: { + baseUrl: string; + onClose: () => void; + onChanged: (label: string) => void; +}): React.JSX.Element { + const [mode, setMode] = useState('menu'); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(''); + const [windows, setWindows] = useState([]); + const [shotUri, setShotUri] = useState(null); + const [shotInfo, setShotInfo] = useState<{ + monLeft: number; monTop: number; monW: number; monH: number; scale: number; + previewW: number; previewH: number; + } | null>(null); + const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null); + const [dragEnd, setDragEnd] = useState<{ x: number; y: number } | null>(null); + + const postSource = useCallback(async (payload: object, label: string) => { + setBusy(true); + try { + await lanHttp('POST', `${baseUrl}/source`, payload, 3000); + onChanged(label); + onClose(); + } catch (e: any) { + setStatus(`failed: ${e?.message ?? e}`); + } finally { + setBusy(false); + } + }, [baseUrl, onChanged, onClose]); + + const onPickScreen = useCallback(() => { + postSource({ source: 'screen', monitor_index: 1 }, 'Full screen'); + }, [postSource]); + + const openWindowList = useCallback(async () => { + setMode('window'); + setBusy(true); + setStatus('loading windows…'); + try { + const list: any = await lanJson('GET', `${baseUrl}/windows`, undefined, 3000); + setWindows(Array.isArray(list) ? list : []); + setStatus(`${Array.isArray(list) ? list.length : 0} windows`); + } catch (e: any) { + setStatus(`failed: ${e?.message ?? e}`); + } finally { + setBusy(false); + } + }, [baseUrl]); + + const openRegion = useCallback(async () => { + setMode('region'); + setBusy(true); + setStatus('grabbing screenshot from Mac…'); + try { + const { downloadAndBake } = await import('../imageProcessor'); + const { DEFAULT_ADJUSTMENTS } = await import('../types'); + const previewMax = 700; + const path = await downloadAndBake( + `${baseUrl}/preview-shot?max=${previewMax}`, + DEFAULT_ADJUSTMENTS, + 0, + 8000, + ); + const st: any = await lanJson('GET', `${baseUrl}/status`, undefined, 3000); + const mon = st?.monitor ?? { left: 0, top: 0, width: 0, height: 0 }; + setShotUri('file://' + path); + Image.getSize('file://' + path, (pw, ph) => { + const monLong = Math.max(mon.width || 0, mon.height || 0) || Math.max(pw, ph); + const scale = monLong > 0 ? pw / (mon.width || pw) : 1.0; + setShotInfo({ + monLeft: mon.left || 0, + monTop: mon.top || 0, + monW: mon.width || pw, + monH: mon.height || ph, + scale: scale > 0 ? scale : 1.0, + previewW: pw, + previewH: ph, + }); + setStatus(`drag a region · mac ${mon.width}×${mon.height}`); + }, () => setStatus('failed to read screenshot')); + } catch (e: any) { + setStatus(`failed: ${e?.message ?? e}`); + } finally { + setBusy(false); + } + }, [baseUrl]); + + const previewLayout = useMemo(() => { + if (!shotInfo) return null; + return { width: shotInfo.previewW, height: shotInfo.previewH }; + }, [shotInfo]); + + const onTouchStart = useCallback((e: GestureResponderEvent) => { + const { locationX, locationY } = e.nativeEvent; + setDragStart({ x: locationX, y: locationY }); + setDragEnd({ x: locationX, y: locationY }); + }, []); + const onTouchMove = useCallback((e: GestureResponderEvent) => { + const { locationX, locationY } = e.nativeEvent; + setDragEnd({ x: locationX, y: locationY }); + }, []); + const onTouchEnd = useCallback(() => { + // no-op; selection committed by "Use selection" button. + }, []); + + const selectionRect = useMemo(() => { + if (!dragStart || !dragEnd) return null; + const x = Math.min(dragStart.x, dragEnd.x); + const y = Math.min(dragStart.y, dragEnd.y); + const w = Math.abs(dragEnd.x - dragStart.x); + const h = Math.abs(dragEnd.y - dragStart.y); + if (w < 8 || h < 8) return null; + return { x, y, w, h }; + }, [dragStart, dragEnd]); + + const onUseRegion = useCallback(() => { + if (!selectionRect || !shotInfo) return; + const invScale = 1.0 / shotInfo.scale; + const region: Region = { + x: Math.round(shotInfo.monLeft + selectionRect.x * invScale), + y: Math.round(shotInfo.monTop + selectionRect.y * invScale), + w: Math.round(selectionRect.w * invScale), + h: Math.round(selectionRect.h * invScale), + }; + postSource({ source: 'region', region }, `Region ${region.w}×${region.h}`); + }, [selectionRect, shotInfo, postSource]); + + if (mode === 'menu') { + return ( + + + + Back + + Mac source + + + + Full screen + + + Window… + + + Region (drag on preview) + + {!!status && {status}} + + {busy ? : null} + + ); + } + + if (mode === 'window') { + return ( + + + setMode('menu')}> + Back + + Pick window + + {status} + String(w.id)} + renderItem={({ item }) => ( + postSource({ source: 'window', window_id: item.id }, `${item.owner}`)} + disabled={busy} + > + {item.owner || '(unknown)'} + + {item.title || '(no title)'} · {item.w}×{item.h} + + + )} + /> + {busy ? : null} + + ); + } + + // region mode + return ( + + + setMode('menu')}> + Back + + Drag region + + {status} + + {shotUri && previewLayout ? ( + true} + onMoveShouldSetResponder={() => true} + onResponderGrant={onTouchStart} + onResponderMove={onTouchMove} + onResponderRelease={onTouchEnd} + > + + {selectionRect ? ( + + ) : null} + + ) : ( + + )} + + + { setDragStart(null); setDragEnd(null); }}> + Clear + + + + Use selection + + + {busy ? : null} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: '#fff' }, + header: { + flexDirection: 'row', alignItems: 'center', gap: 12, + paddingHorizontal: 16, paddingVertical: 12, + borderBottomWidth: 1, borderBottomColor: '#000', + }, + title: { flex: 1, fontSize: 22, fontWeight: '600', color: '#000' }, + status: { fontSize: 12, color: '#444', paddingHorizontal: 16, paddingVertical: 6 }, + btn: { paddingHorizontal: 14, paddingVertical: 8, borderWidth: 1, borderColor: '#000' }, + btnActive: { backgroundColor: '#000' }, + btnTxt: { fontSize: 14, color: '#000' }, + btnTxtPrimary: { color: '#fff' }, + body: { padding: 16, gap: 12 }, + bigBtn: { + paddingVertical: 18, paddingHorizontal: 14, + borderWidth: 1, borderColor: '#000', alignItems: 'center', + }, + bigBtnTxt: { fontSize: 16, color: '#000' }, + windowRow: { + paddingVertical: 12, paddingHorizontal: 16, + borderBottomWidth: 1, borderBottomColor: '#eee', + }, + windowOwner: { fontSize: 15, color: '#000', fontWeight: '500' }, + windowTitle: { fontSize: 12, color: '#666', marginTop: 2 }, + previewWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 12 }, + previewBox: { borderWidth: 1, borderColor: '#000', overflow: 'hidden' }, + selRect: { + position: 'absolute', + borderWidth: 2, borderColor: '#000', + backgroundColor: 'rgba(0,0,0,0.1)', + }, + row: { + flexDirection: 'row', gap: 8, + paddingHorizontal: 12, paddingVertical: 8, + borderTopWidth: 1, borderTopColor: '#ccc', + alignItems: 'center', + }, + overlay: { + position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, + backgroundColor: 'rgba(255,255,255,0.6)', + alignItems: 'center', justifyContent: 'center', + }, +}); diff --git a/src/storage.ts b/src/storage.ts index b0e8fbe..5735744 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -15,6 +15,10 @@ export async function loadStreamConfig(): Promise { port: typeof parsed.port === 'number' ? parsed.port : DEFAULT_STREAM_CONFIG.port, intervalSec: typeof parsed.intervalSec === 'number' ? parsed.intervalSec : DEFAULT_STREAM_CONFIG.intervalSec, + resolutionMul: + typeof parsed.resolutionMul === 'number' + ? Math.max(0.1, Math.min(1.0, parsed.resolutionMul)) + : DEFAULT_STREAM_CONFIG.resolutionMul, }; } catch { return DEFAULT_STREAM_CONFIG; diff --git a/src/types.ts b/src/types.ts index d951b4d..5aca9ac 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ export type SortKey = 'date_desc' | 'date_asc' | 'name'; export type EntryKind = 'image' | 'folder'; export type Entry = { name: string; path: string; kind: EntryKind }; -export type Screen = 'browser' | 'preview' | 'settings' | 'capture' | 'refresh'; +export type Screen = 'browser' | 'preview' | 'settings' | 'capture' | 'refresh' | 'sourcepicker'; export type Adjustments = { fade: number; @@ -21,14 +21,28 @@ export type StreamConfig = { host: string; port: number; intervalSec: number; + resolutionMul: number; // 0.1 .. 1.0, server downscales each frame }; export const DEFAULT_STREAM_CONFIG: StreamConfig = { host: '', port: 9000, intervalSec: 1.0, + resolutionMul: 1.0, }; +export type SourceKind = 'screen' | 'window' | 'region'; +export type MacWindow = { + id: number; + owner: string; + title: string; + x: number; + y: number; + w: number; + h: number; +}; +export type Region = { x: number; y: number; w: number; h: number }; + export type EmbedTrack = { notePath: string; page: number; diff --git a/src/useConnStatus.ts b/src/useConnStatus.ts new file mode 100644 index 0000000..6891152 --- /dev/null +++ b/src/useConnStatus.ts @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react'; +import { lanJson } from './imageProcessor'; + +// Polls the Mac /status endpoint to expose a coarse connection state to +// the UI's status dot. Each consumer screen creates its own hook instance; +// network use is tiny (small JSON every few seconds) and identity +// adjustments / capture polling are unaffected. +export type ConnStatus = 'unknown' | 'connected' | 'disconnected'; + +const POLL_MS = 5000; +const TIMEOUT_MS = 2000; + +export function useConnStatus(baseUrl: string): ConnStatus { + const [status, setStatus] = useState('unknown'); + + useEffect(() => { + if (!baseUrl) { + setStatus('unknown'); + return; + } + let alive = true; + let timer: ReturnType | null = null; + + const tick = async () => { + try { + await lanJson('GET', `${baseUrl}/status`, undefined, TIMEOUT_MS); + if (alive) setStatus('connected'); + } catch { + if (alive) setStatus('disconnected'); + } finally { + if (alive) timer = setTimeout(tick, POLL_MS); + } + }; + tick(); + return () => { + alive = false; + if (timer) clearTimeout(timer); + }; + }, [baseUrl]); + + return status; +} From 238a786046e3179bede50e82c27c0a6eb09d0cf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 11:17:49 +0000 Subject: [PATCH 11/28] Win95 makeover, CRT region picker, dither, watch folder, lasso -> Mac, presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI: Windows 95 chrome everywhere - New theme.ts / Win95.tsx with raised/inset bevels, TitleBar with X close, MenuBar with drop-down menus, Win95Button, StatusBar. - VT323 pixel font bundled (android/app/src/main/assets/fonts/). - Browser, Capture, Preview, Settings, Refresh, SourcePicker all re-skinned. Folder thumbnails read "[DIR]", logs read "C:\>_". - StatusDot is now a tiny bezeled LED. CRT screensaver region picker - The region mode of SourcePicker now mimics a beige '90s monitor: rounded plastic frame with stand + knobs + power LED, scanline overlay, phosphor-green selection rectangle + corner tags ("SOURCE-1", size readout), flicker animation, "NO SIGNAL" placeholder. Image filters (#1 dither) - New dither modes baked into the Mac /adjust pipeline: off, 1-bit Floyd-Steinberg, 4-level gray (Manta's native range), Atkinson. - Adjustments type gained `dither: DitherMode`. - AdjustmentPanel grew a Dither chip row. Watch folder (#4 dropbox / airdrop magic) - Mac creates ~/EmbedImage/Drop on startup. New endpoints: /drop/list, /drop/file?name=X, /drop/consume {name}. Status now exposes drop_count + watch_folder for badges. - New DROP.EXE sidebar button (id=3) opens a Win95 inbox that lists files newest-first with thumbnails. Tap to embed; Mac moves the file to .consumed/ so it won't show up again. Lasso to Mac (#5 reverse direction) - New lasso-toolbar button (id=4, type=2) "Send to Mac". On press, uses PluginCommAPI.generateLassoPreview to export the lasso as PNG, POSTs to /sketch?name=…, server stashes it in ~/EmbedImage/Sketches. Adjustment presets (#9) - Mac persists named presets in ~/EmbedImage/presets.json. - /presets GET, POST {name, ...}, DELETE /presets/. - AdjustmentPanel shows a horizontal preset chip row at the top with a Save… inline field and long-press-to-delete on existing chips. - lanHttp / lanJson now also accept DELETE and PUT methods. Refresh screen - Now a Win95 progress dialog with a marching-pixel progress bar. Bump 0.6.0 -> 0.7.0 / versionCode 16 -> 17. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- App.tsx | 20 +- PluginConfig.json | 4 +- .../src/main/assets/fonts/VT323-Regular.ttf | Bin 0 -> 153116 bytes index.js | 32 +- macapp/server.py | 211 +++++++++- src/AdjustmentPanel.tsx | 320 ++++++++++----- src/StatusDot.tsx | 33 +- src/imageProcessor.ts | 7 +- src/screens/Browser.tsx | 263 ++++++------ src/screens/Capture.tsx | 218 +++++----- src/screens/DropInbox.tsx | 178 +++++++++ src/screens/Preview.tsx | 80 ++-- src/screens/Refresh.tsx | 72 +++- src/screens/SendLasso.tsx | 97 +++++ src/screens/Settings.tsx | 190 ++++----- src/screens/SourcePicker.tsx | 378 +++++++++++------- src/types.ts | 23 +- src/ui/Win95.tsx | 255 ++++++++++++ src/ui/theme.ts | 90 +++++ 19 files changed, 1765 insertions(+), 706 deletions(-) create mode 100644 android/app/src/main/assets/fonts/VT323-Regular.ttf create mode 100644 src/screens/DropInbox.tsx create mode 100644 src/screens/SendLasso.tsx create mode 100644 src/ui/Win95.tsx create mode 100644 src/ui/theme.ts diff --git a/App.tsx b/App.tsx index cbd9d10..ecce710 100644 --- a/App.tsx +++ b/App.tsx @@ -3,15 +3,19 @@ import { PluginManager } from 'sn-plugin-lib'; import { ImageProcessor } from './src/imageProcessor'; import { BrowserScreen } from './src/screens/Browser'; import { CaptureScreen } from './src/screens/Capture'; +import { DropInbox } from './src/screens/DropInbox'; import { PreviewScreen } from './src/screens/Preview'; import { RefreshScreen } from './src/screens/Refresh'; +import { SendLasso } from './src/screens/SendLasso'; import { SettingsScreen } from './src/screens/Settings'; import { SourcePicker } from './src/screens/SourcePicker'; import { baseUrl, loadStreamConfig } from './src/storage'; import { DEFAULT_STREAM_CONFIG, Entry, Screen, StreamConfig } from './src/types'; -// Must match BUTTON_REFRESH in index.js. +// Must match index.js. const BUTTON_REFRESH = 2; +const BUTTON_DROP = 3; +const BUTTON_LASSO_SEND = 4; export default function App(): React.JSX.Element { const [screen, setScreen] = useState('browser'); @@ -25,13 +29,11 @@ export default function App(): React.JSX.Element { setStreamConfig(cfg); })(); - // Route based on which sidebar button was tapped. PluginManager replays - // the last button event to a freshly-registered listener, so the local - // subscription here will fire immediately if we were launched by the - // Refresh button. const sub = PluginManager.registerButtonListener({ onButtonPress: (msg: any) => { if (msg?.id === BUTTON_REFRESH) setScreen('refresh'); + else if (msg?.id === BUTTON_DROP) setScreen('dropinbox'); + else if (msg?.id === BUTTON_LASSO_SEND) setScreen('sendlasso'); }, }); return () => { @@ -53,6 +55,14 @@ export default function App(): React.JSX.Element { return ; } + if (screen === 'dropinbox') { + return ; + } + + if (screen === 'sendlasso') { + return ; + } + if (screen === 'preview' && selectedEntry) { return ; } diff --git a/PluginConfig.json b/PluginConfig.json index 3af15a1..1facf82 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.6.0", - "versionCode": "16", + "versionName": "0.7.0", + "versionCode": "17", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/android/app/src/main/assets/fonts/VT323-Regular.ttf b/android/app/src/main/assets/fonts/VT323-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..afa69098f15bf64849d397f133d9467486904053 GIT binary patch literal 153116 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 literal 0 HcmV?d00001 diff --git a/index.js b/index.js index faf5485..6aebd86 100644 --- a/index.js +++ b/index.js @@ -7,16 +7,16 @@ AppRegistry.registerComponent(appName, () => App); PluginManager.init(); -// Button IDs. App.tsx reads the last pressed id off PluginManager and -// switches to the "refresh" headless flow if it sees BUTTON_REFRESH. +// Button IDs. App.tsx watches the most recent press and routes to the +// matching headless screen if applicable. export const BUTTON_MAIN = 1; export const BUTTON_REFRESH = 2; +export const BUTTON_DROP = 3; +export const BUTTON_LASSO_SEND = 4; // lasso-toolbar button (type=2) const ICON = Image.resolveAssetSource(require('./assets/icon.png')).uri; -// type=1: sidebar button. showType=1: tap opens the plugin view (App.tsx). -// Both buttons go through the same view; App.tsx routes based on the -// pressed button id. +// Sidebar buttons (type=1). PluginManager.registerButton(1, ['NOTE'], { id: BUTTON_MAIN, name: 'Embed Image', @@ -31,8 +31,28 @@ PluginManager.registerButton(1, ['NOTE'], { showType: 1, }); +PluginManager.registerButton(1, ['NOTE'], { + id: BUTTON_DROP, + name: 'Drop Inbox', + icon: ICON, + showType: 1, +}); + +// Lasso-toolbar button (type=2). Appears when the user lasso-selects ink +// on the page; tap to ship the selection to the Mac. +try { + PluginManager.registerButton(2, ['NOTE'], { + id: BUTTON_LASSO_SEND, + name: 'Send to Mac', + icon: ICON, + showType: 1, + }); +} catch (e) { + // Older host versions may not support type=2; fall back silently. +} + PluginManager.registerButtonListener({ onButtonPress: (_msg) => { - // Routing happens in App.tsx via PluginManager.lastButtonEventMsg. + // Routing happens in App.tsx via the listener it registers there. }, }); diff --git a/macapp/server.py b/macapp/server.py index 4e34f48..111b879 100644 --- a/macapp/server.py +++ b/macapp/server.py @@ -12,7 +12,7 @@ POST /stop -> pause capture loop POST /source {source, ...} -> change capture source (full screen, region, window) POST /interval {interval_sec} -> change capture interval -POST /adjust {fade, brightness, contrast, gamma} +POST /adjust {fade, brightness, contrast, gamma, dither} -> apply tone adjustments to served frames POST /resolution {mul} -> downscale factor (0.1..1.0) applied to frames GET /windows -> JSON list of visible windows from Quartz @@ -22,28 +22,62 @@ is PNG with background removed (composited onto white). Requires `torch` and `transformers` to be installed. +GET /drop/list -> JSON list of files dropped into the watch + folder, newest first +GET /drop/file?name= -> binary content of a dropped file +POST /drop/consume {name} -> mark a dropped file as consumed (renames + it out of the watch folder) +POST /sketch -> request body is PNG bytes (a lasso export + from the Manta); saves to ~/Drop with a + timestamped filename so the user can pick + it up +GET /presets -> JSON list of named adjustment presets +POST /presets {name, ...} -> save a preset (overwrites by name) +DELETE /presets/ -> remove a preset """ from __future__ import annotations import io +import json import logging +import os import socket import threading import time from dataclasses import dataclass, field +from pathlib import Path from typing import Any, Optional import mss -from flask import Flask, jsonify, request +from flask import Flask, jsonify, request, send_file from PIL import Image, ImageEnhance +# Persisted data lives here. Watch folder is what users drop / airdrop +# files into; presets file holds named adjustment combos. +DATA_DIR = Path.home() / "EmbedImage" +WATCH_DIR = DATA_DIR / "Drop" +CONSUMED_DIR = DATA_DIR / "Drop" / ".consumed" +PRESETS_PATH = DATA_DIR / "presets.json" +SKETCHES_DIR = DATA_DIR / "Sketches" +for _d in (DATA_DIR, WATCH_DIR, CONSUMED_DIR, SKETCHES_DIR): + _d.mkdir(parents=True, exist_ok=True) + +DROP_EXTS = {".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp", ".tiff", ".tif"} + # Adjustment ranges must match src/AdjustmentPanel.tsx on the plugin side: # fade 0..100 (% blended toward white) # brightness -100..100 # contrast -100..100 # gamma 0.2..3.0 (1.0 = identity) -_DEFAULT_ADJUST = {"fade": 0, "brightness": 0, "contrast": 0, "gamma": 1.0} +# dither "none" | "fs1" | "fs4" | "atkinson" (Manta has 4-level gray) +_DEFAULT_ADJUST = { + "fade": 0, + "brightness": 0, + "contrast": 0, + "gamma": 1.0, + "dither": "none", +} def _is_identity_adjust(a: dict) -> bool: @@ -52,11 +86,45 @@ def _is_identity_adjust(a: dict) -> bool: and int(a.get("brightness", 0)) == 0 and int(a.get("contrast", 0)) == 0 and abs(float(a.get("gamma", 1.0)) - 1.0) < 1e-6 + and str(a.get("dither", "none")) == "none" ) +def _dither(img: Image.Image, mode: str) -> Image.Image: + """Quantize to a small palette using error-diffusion. PIL's built-in + Floyd-Steinberg via convert('1') / convert('P') is fine for most; + we expose 1-bit ('fs1') and 4-level grayscale ('fs4', the Manta's + native range) and Atkinson (classic Mac-look) implemented manually.""" + if mode == "fs1": + return img.convert("L").convert("1").convert("RGB") + if mode == "fs4": + # 4-level gray palette (black, dark gray, light gray, white). + pal_img = Image.new("P", (1, 1)) + pal = [0, 0, 0, 85, 85, 85, 170, 170, 170, 255, 255, 255] + [0] * (256 * 3 - 12) + pal_img.putpalette(pal) + g = img.convert("L").convert("RGB") + return g.quantize(palette=pal_img, dither=Image.FLOYDSTEINBERG).convert("RGB") + if mode == "atkinson": + import numpy as np + + arr = np.asarray(img.convert("L"), dtype=np.float32).copy() + h, w = arr.shape + for y in range(h): + for x in range(w): + old = arr[y, x] + new = 0.0 if old < 128 else 255.0 + arr[y, x] = new + err = (old - new) / 8.0 + for dx, dy in ((1, 0), (2, 0), (-1, 1), (0, 1), (1, 1), (0, 2)): + nx, ny = x + dx, y + dy + if 0 <= nx < w and 0 <= ny < h: + arr[ny, nx] += err + return Image.fromarray(arr.clip(0, 255).astype("uint8"), "L").convert("RGB") + return img + + def _apply_adjust(img: Image.Image, a: dict) -> Image.Image: - """Apply brightness/contrast/gamma/fade to an RGB PIL image.""" + """Apply brightness/contrast/gamma/fade/dither to an RGB PIL image.""" if _is_identity_adjust(a): return img out = img @@ -64,9 +132,9 @@ def _apply_adjust(img: Image.Image, a: dict) -> Image.Image: contrast = int(a.get("contrast", 0)) gamma = float(a.get("gamma", 1.0)) fade = int(a.get("fade", 0)) + dither = str(a.get("dither", "none")) if brightness: - # -100..100 maps to PIL factor 0..2 (1.0 = unchanged). out = ImageEnhance.Brightness(out).enhance(1.0 + brightness / 100.0) if contrast: out = ImageEnhance.Contrast(out).enhance(1.0 + contrast / 100.0) @@ -78,6 +146,8 @@ def _apply_adjust(img: Image.Image, a: dict) -> Image.Image: alpha = max(0, min(100, fade)) / 100.0 white = Image.new("RGB", out.size, (255, 255, 255)) out = Image.blend(out, white, alpha) + if dither and dither != "none": + out = _dither(out, dither) return out try: @@ -277,6 +347,8 @@ def status(): "last_error": STATE.last_error, "ip": get_local_ip(), "monitor": mon, + "drop_count": len(_drop_list()), + "watch_folder": str(WATCH_DIR), } ) @@ -333,6 +405,9 @@ def set_interval(): return jsonify({"ok": True, "interval_sec": STATE.interval_sec}) +_VALID_DITHER = {"none", "fs1", "fs4", "atkinson"} + + @app.route("/adjust", methods=["POST"]) def set_adjust(): body = request.get_json(force=True, silent=True) or {} @@ -346,6 +421,9 @@ def set_adjust(): new["contrast"] = max(-100, min(100, int(body["contrast"]))) if "gamma" in body: new["gamma"] = max(0.2, min(3.0, float(body["gamma"]))) + if "dither" in body: + d = str(body["dither"] or "none") + new["dither"] = d if d in _VALID_DITHER else "none" except (TypeError, ValueError): return jsonify({"error": "invalid adjust value"}), 400 STATE.adjust = new @@ -508,6 +586,129 @@ def birefnet(): return buf.getvalue(), 200, {"Content-Type": "image/png"} +# ---- Watch folder (drop / airdrop area) ---- + +def _drop_list() -> list: + items = [] + for p in WATCH_DIR.iterdir(): + if not p.is_file(): + continue + if p.name.startswith("."): + continue + if p.suffix.lower() not in DROP_EXTS: + continue + st = p.stat() + items.append({ + "name": p.name, + "size": st.st_size, + "mtime": st.st_mtime, + }) + items.sort(key=lambda x: -x["mtime"]) + return items + + +@app.route("/drop/list") +def drop_list(): + return jsonify({ + "folder": str(WATCH_DIR), + "items": _drop_list(), + }) + + +@app.route("/drop/file") +def drop_file(): + name = request.args.get("name", "") + if "/" in name or name.startswith("."): + return jsonify({"error": "invalid name"}), 400 + path = WATCH_DIR / name + if not path.is_file(): + return jsonify({"error": "not found"}), 404 + return send_file(str(path), mimetype="application/octet-stream") + + +@app.route("/drop/consume", methods=["POST"]) +def drop_consume(): + body = request.get_json(force=True, silent=True) or {} + name = str(body.get("name", "")) + if "/" in name or name.startswith("."): + return jsonify({"error": "invalid name"}), 400 + path = WATCH_DIR / name + if not path.is_file(): + return jsonify({"error": "not found"}), 404 + dst = CONSUMED_DIR / f"{int(time.time())}_{path.name}" + path.rename(dst) + log(f"drop consumed: {name}") + return jsonify({"ok": True}) + + +# ---- Sketches uploaded from the Manta (lasso -> Mac) ---- + +@app.route("/sketch", methods=["POST"]) +def sketch(): + raw = request.get_data(cache=False) + if not raw: + return jsonify({"error": "no body"}), 400 + name = request.args.get("name", "") + if not name: + name = f"manta_{time.strftime('%Y%m%d_%H%M%S')}.png" + if "/" in name or name.startswith("."): + return jsonify({"error": "invalid name"}), 400 + path = SKETCHES_DIR / name + path.write_bytes(raw) + log(f"sketch saved: {path}") + return jsonify({"ok": True, "path": str(path), "name": name}) + + +# ---- Adjustment presets ---- + +def _read_presets() -> list: + if not PRESETS_PATH.exists(): + return [] + try: + data = json.loads(PRESETS_PATH.read_text()) + return data if isinstance(data, list) else [] + except (json.JSONDecodeError, OSError): + return [] + + +def _write_presets(items: list) -> None: + PRESETS_PATH.write_text(json.dumps(items, indent=2)) + + +@app.route("/presets") +def get_presets(): + return jsonify(_read_presets()) + + +@app.route("/presets", methods=["POST"]) +def save_preset(): + body = request.get_json(force=True, silent=True) or {} + name = str(body.get("name", "")).strip() + if not name: + return jsonify({"error": "name required"}), 400 + preset = { + "name": name, + "fade": int(body.get("fade", 0)), + "brightness": int(body.get("brightness", 0)), + "contrast": int(body.get("contrast", 0)), + "gamma": float(body.get("gamma", 1.0)), + "dither": str(body.get("dither", "none")), + } + items = [p for p in _read_presets() if p.get("name") != name] + items.append(preset) + _write_presets(items) + log(f"preset saved: {name}") + return jsonify({"ok": True, "preset": preset, "presets": items}) + + +@app.route("/presets/", methods=["DELETE"]) +def delete_preset(name): + items = [p for p in _read_presets() if p.get("name") != name] + _write_presets(items) + log(f"preset deleted: {name}") + return jsonify({"ok": True, "presets": items}) + + def run_server(host: str, port: int) -> None: from werkzeug.serving import make_server diff --git a/src/AdjustmentPanel.tsx b/src/AdjustmentPanel.tsx index 365b15e..14b2f28 100644 --- a/src/AdjustmentPanel.tsx +++ b/src/AdjustmentPanel.tsx @@ -1,9 +1,14 @@ -import React, { useState } from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; +import React, { useEffect, useState } from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; import { RangeSlider } from './RangeSlider'; -import type { Adjustments } from './types'; +import { lanHttp, lanJson } from './imageProcessor'; +import { baseUrl, loadStreamConfig } from './storage'; +import { theme } from './ui/theme'; +import { Win95Button, Win95InsetPanel } from './ui/Win95'; +import type { Adjustments, DitherMode, Preset } from './types'; +import { DEFAULT_ADJUSTMENTS, DITHER_LABELS } from './types'; -type AdjustmentKey = keyof Adjustments; +type NumericKey = 'fade' | 'brightness' | 'contrast' | 'gamma'; type Config = { label: string; @@ -15,45 +20,15 @@ type Config = { format: (v: number) => string; }; -const ADJUSTMENT_CONFIG: Record = { - fade: { - label: 'Fade', - min: 0, - max: 100, - step: 5, - default: 0, - presets: [0, 25, 50, 75, 100], - format: (v) => `${Math.round(v)}%`, - }, - brightness: { - label: 'Brightness', - min: -100, - max: 100, - step: 5, - default: 0, - presets: [-50, -25, 0, 25, 50], - format: (v) => `${v > 0 ? '+' : ''}${Math.round(v)}`, - }, - contrast: { - label: 'Contrast', - min: -100, - max: 100, - step: 5, - default: 0, - presets: [-50, -25, 0, 25, 50], - format: (v) => `${v > 0 ? '+' : ''}${Math.round(v)}`, - }, - gamma: { - label: 'Gamma', - min: 0.5, - max: 2.0, - step: 0.05, - default: 1.0, - presets: [0.5, 0.75, 1.0, 1.5, 2.0], - format: (v) => v.toFixed(2), - }, +const ADJUSTMENT_CONFIG: Record = { + fade: { label: 'Fade', min: 0, max: 100, step: 5, default: 0, presets: [0, 25, 50, 75, 100], format: (v) => `${Math.round(v)}%` }, + brightness: { label: 'Brightness', min: -100, max: 100, step: 5, default: 0, presets: [-50, -25, 0, 25, 50], format: (v) => `${v > 0 ? '+' : ''}${Math.round(v)}` }, + contrast: { label: 'Contrast', min: -100, max: 100, step: 5, default: 0, presets: [-50, -25, 0, 25, 50], format: (v) => `${v > 0 ? '+' : ''}${Math.round(v)}` }, + gamma: { label: 'Gamma', min: 0.5, max: 2.0, step: 0.05, default: 1.0, presets: [0.5, 0.75, 1.0, 1.5, 2.0], format: (v) => v.toFixed(2) }, }; +const DITHER_MODES: DitherMode[] = ['none', 'fs1', 'fs4', 'atkinson']; + function clamp(n: number, lo: number, hi: number): number { return Math.max(lo, Math.min(hi, n)); } @@ -71,16 +46,123 @@ export function AdjustmentPanel({ onChange: (next: Adjustments) => void; disabled?: boolean; }): React.JSX.Element { - const [activeKey, setActiveKey] = useState('fade'); + const [activeKey, setActiveKey] = useState('fade'); + const [presets, setPresets] = useState([]); + const [showSave, setShowSave] = useState(false); + const [newPresetName, setNewPresetName] = useState(''); + const cfg = ADJUSTMENT_CONFIG[activeKey]; const value = values[activeKey]; const setValue = (v: number) => onChange({ ...values, [activeKey]: v }); const round = (v: number) => Math.round(v / cfg.step) * cfg.step; + // Load presets from the Mac when mounted (best-effort). + useEffect(() => { + (async () => { + const sc = await loadStreamConfig(); + const url = baseUrl(sc); + if (!url) return; + try { + const list: any = await lanJson('GET', `${url}/presets`, undefined, 3000); + if (Array.isArray(list)) setPresets(list); + } catch { + // server may not be reachable; presets stay empty. + } + })(); + }, []); + + const applyPreset = (p: Preset) => { + onChange({ + fade: p.fade ?? 0, + brightness: p.brightness ?? 0, + contrast: p.contrast ?? 0, + gamma: p.gamma ?? 1.0, + dither: (p.dither ?? 'none') as DitherMode, + }); + }; + + const saveCurrentAsPreset = async () => { + const name = newPresetName.trim(); + if (!name) return; + const sc = await loadStreamConfig(); + const url = baseUrl(sc); + if (!url) { + setShowSave(false); + return; + } + try { + const res: any = await lanHttp('POST', `${url}/presets`, { name, ...values }, 3000); + const parsed = JSON.parse(res?.body ?? '{}'); + if (Array.isArray(parsed?.presets)) setPresets(parsed.presets); + } catch { + // ignore — UI keeps working without preset persistence. + } + setShowSave(false); + setNewPresetName(''); + }; + + const deletePreset = async (name: string) => { + const sc = await loadStreamConfig(); + const url = baseUrl(sc); + if (!url) return; + try { + await lanHttp('DELETE', `${url}/presets/${encodeURIComponent(name)}`, undefined, 3000); + const list: any = await lanJson('GET', `${url}/presets`, undefined, 3000); + if (Array.isArray(list)) setPresets(list); + } catch { + // ignore + } + }; + + const resetAll = () => onChange(DEFAULT_ADJUSTMENTS); + return ( - + + {/* Preset chips */} + + Presets: + + {presets.length === 0 ? ( + (none — Save current… to add) + ) : ( + presets.map((p) => ( + applyPreset(p)} + onLongPress={() => deletePreset(p.name)} + style={styles.presetChip} + disabled={disabled} + > + {p.name} + + )) + )} + + setShowSave((s) => !s)} disabled={disabled}>Save… + Reset + + + {showSave ? ( + + + + + OK + setShowSave(false)}>X + + ) : null} + + {/* Tab strip */} - {(Object.keys(ADJUSTMENT_CONFIG) as AdjustmentKey[]).map((key) => { + {(Object.keys(ADJUSTMENT_CONFIG) as NumericKey[]).map((key) => { const c = ADJUSTMENT_CONFIG[key]; const active = key === activeKey; const dirty = !equalish(values[key], c.default); @@ -92,34 +174,26 @@ export function AdjustmentPanel({ disabled={disabled} > - {c.label} - {dirty ? ' •' : ''} + {c.label}{dirty ? ' •' : ''} ); })} + {/* Active control */} {cfg.label} {cfg.format(value)} - setValue(cfg.default)} - disabled={disabled} - > - Reset - + setValue(cfg.default)} disabled={disabled}>Reset - setValue(clamp(round(value - cfg.step), cfg.min, cfg.max))} disabled={disabled} - > - - + >− - setValue(clamp(round(value + cfg.step), cfg.min, cfg.max))} disabled={disabled} - > - + - + >+ - + {cfg.presets.map((p) => { const active = equalish(p, value); return ( setValue(p)} - style={[styles.preset, active && styles.presetActive]} + style={[styles.quickPreset, active && styles.quickPresetActive]} disabled={disabled} > - + {cfg.format(p)} ); })} + + {/* Dither selector */} + + Dither: + {DITHER_MODES.map((m) => { + const active = values.dither === m; + return ( + onChange({ ...values, dither: m })} + style={[styles.ditherChip, active && styles.ditherChipActive]} + disabled={disabled} + > + + {DITHER_LABELS[m]} + + + ); + })} + ); } const styles = StyleSheet.create({ - tabRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#000' }, + root: { backgroundColor: theme.bg, borderTopWidth: 1, borderTopColor: theme.shadow }, + presetBar: { + flexDirection: 'row', alignItems: 'center', gap: 6, + paddingHorizontal: 6, paddingVertical: 4, + }, + presetBarLabel: { fontFamily: 'VT323', fontSize: 14, color: theme.text }, + presetChips: { alignItems: 'center', gap: 6, paddingRight: 6 }, + presetEmpty: { fontFamily: 'VT323', fontSize: 13, color: theme.shadow, paddingHorizontal: 4 }, + presetChip: { + paddingHorizontal: 8, paddingVertical: 4, + backgroundColor: theme.bg, + borderTopWidth: 1, borderLeftWidth: 1, borderRightWidth: 1, borderBottomWidth: 1, + borderTopColor: theme.highlight, borderLeftColor: theme.highlight, + borderRightColor: theme.dark, borderBottomColor: theme.dark, + }, + presetChipTxt: { fontFamily: 'VT323', fontSize: 14, color: theme.text }, + saveRow: { + flexDirection: 'row', alignItems: 'center', gap: 6, + paddingHorizontal: 6, paddingBottom: 4, + }, + saveInput: { flex: 1, padding: 0 }, + saveInputTxt: { fontFamily: 'VT323', fontSize: 14, color: theme.text, paddingHorizontal: 6, paddingVertical: 4 }, + tabRow: { + flexDirection: 'row', + backgroundColor: theme.bg, + borderTopWidth: 1, borderTopColor: theme.shadow, + }, tab: { - flex: 1, paddingVertical: 10, alignItems: 'center', justifyContent: 'center', - borderRightWidth: 1, borderRightColor: '#000', + flex: 1, paddingVertical: 6, alignItems: 'center', justifyContent: 'center', }, - tabActive: { backgroundColor: '#000' }, - tabTxt: { fontSize: 14, color: '#000' }, - tabTxtActive: { color: '#fff' }, + tabActive: { backgroundColor: theme.titleBg }, + tabTxt: { fontFamily: 'VT323', fontSize: 14, color: theme.text }, + tabTxtActive: { color: theme.titleFg }, header: { flexDirection: 'row', alignItems: 'center', gap: 8, - paddingHorizontal: 12, paddingTop: 10, + paddingHorizontal: 6, paddingTop: 6, }, - headerLabel: { flex: 1, fontSize: 14, color: '#000' }, - headerValue: { fontSize: 14, color: '#000', fontVariant: ['tabular-nums'] }, - resetBtn: { paddingHorizontal: 10, paddingVertical: 4, borderWidth: 1, borderColor: '#000' }, - resetBtnTxt: { fontSize: 12, color: '#000' }, + headerLabel: { flex: 1, fontFamily: 'VT323', fontSize: 14, color: theme.text }, + headerValue: { fontFamily: 'VT323', fontSize: 14, color: theme.text }, sliderRow: { - flexDirection: 'row', alignItems: 'center', gap: 10, - paddingHorizontal: 12, paddingVertical: 6, + flexDirection: 'row', alignItems: 'center', gap: 6, + paddingHorizontal: 6, paddingVertical: 4, }, sliderWrap: { flex: 1 }, - stepBtn: { - width: 40, height: 36, alignItems: 'center', justifyContent: 'center', - borderWidth: 1, borderColor: '#000', + quickPresetRow: { + flexDirection: 'row', gap: 4, flexWrap: 'wrap', + paddingHorizontal: 6, paddingVertical: 4, + }, + quickPreset: { + paddingHorizontal: 8, paddingVertical: 3, + backgroundColor: theme.bg, + borderTopWidth: 1, borderLeftWidth: 1, borderRightWidth: 1, borderBottomWidth: 1, + borderTopColor: theme.highlight, borderLeftColor: theme.highlight, + borderRightColor: theme.dark, borderBottomColor: theme.dark, + }, + quickPresetActive: { + backgroundColor: theme.titleBg, + borderTopColor: theme.dark, borderLeftColor: theme.dark, + borderRightColor: theme.highlight, borderBottomColor: theme.highlight, + }, + quickPresetTxt: { fontFamily: 'VT323', fontSize: 13, color: theme.text }, + quickPresetTxtActive: { color: theme.titleFg }, + ditherRow: { + flexDirection: 'row', alignItems: 'center', gap: 6, + paddingHorizontal: 6, paddingVertical: 6, + borderTopWidth: 1, borderTopColor: theme.shadow, + }, + ditherLabel: { fontFamily: 'VT323', fontSize: 14, color: theme.text, marginRight: 4 }, + ditherChip: { + paddingHorizontal: 8, paddingVertical: 4, + backgroundColor: theme.bg, + borderTopWidth: 1, borderLeftWidth: 1, borderRightWidth: 1, borderBottomWidth: 1, + borderTopColor: theme.highlight, borderLeftColor: theme.highlight, + borderRightColor: theme.dark, borderBottomColor: theme.dark, }, - stepBtnTxt: { fontSize: 18, color: '#000' }, - presetRow: { - flexDirection: 'row', gap: 8, flexWrap: 'wrap', - paddingHorizontal: 12, paddingVertical: 6, + ditherChipActive: { + backgroundColor: theme.titleBg, + borderTopColor: theme.dark, borderLeftColor: theme.dark, + borderRightColor: theme.highlight, borderBottomColor: theme.highlight, }, - preset: { paddingHorizontal: 10, paddingVertical: 6, borderWidth: 1, borderColor: '#000' }, - presetActive: { backgroundColor: '#000' }, - presetTxt: { fontSize: 13, color: '#000' }, - presetTxtActive: { color: '#fff' }, + ditherChipTxt: { fontFamily: 'VT323', fontSize: 14, color: theme.text }, + ditherChipTxtActive: { color: theme.titleFg }, }); diff --git a/src/StatusDot.tsx b/src/StatusDot.tsx index f09a2f1..12ce4ca 100644 --- a/src/StatusDot.tsx +++ b/src/StatusDot.tsx @@ -1,25 +1,30 @@ import React from 'react'; import { StyleSheet, View } from 'react-native'; +import { theme } from './ui/theme'; import { ConnStatus } from './useConnStatus'; -// Small filled circle for the connection state. Manta is monochrome, so: -// connected -> solid black -// disconnected -> white with a black ring -// unknown -> open ring (no fill) +// 8x8 pixel-art "LED" indicator for the Mac-server connection. Manta is +// monochrome but the bevels read clearly: filled = connected, hollow = +// disconnected, no border = unknown. export function StatusDot({ status }: { status: ConnStatus }): React.JSX.Element { - const fill = - status === 'connected' ? '#000' : - status === 'disconnected' ? '#fff' : '#fff'; - return ; + const fill = status === 'connected' ? theme.text : theme.inset; + return ( + + + + ); } const styles = StyleSheet.create({ - dot: { - width: 14, - height: 14, - borderRadius: 7, - borderWidth: 1, - borderColor: '#000', + bezel: { + width: 14, height: 14, + backgroundColor: theme.bg, + borderTopWidth: 1, borderLeftWidth: 1, + borderRightWidth: 1, borderBottomWidth: 1, + borderTopColor: theme.shadow, borderLeftColor: theme.shadow, + borderRightColor: theme.highlight, borderBottomColor: theme.highlight, + alignItems: 'center', justifyContent: 'center', marginLeft: 8, }, + dot: { width: 8, height: 8 }, }); diff --git a/src/imageProcessor.ts b/src/imageProcessor.ts index 2761411..733ccab 100644 --- a/src/imageProcessor.ts +++ b/src/imageProcessor.ts @@ -47,7 +47,8 @@ export function adjustmentsAreDefault(a: Adjustments): boolean { a.fade === 0 && a.brightness === 0 && a.contrast === 0 && - Math.abs(a.gamma - 1.0) < 1e-6 + Math.abs(a.gamma - 1.0) < 1e-6 && + (a.dither ?? 'none') === 'none' ); } @@ -86,7 +87,7 @@ export async function downloadAndBake( } export async function lanHttp( - method: 'GET' | 'POST', + method: 'GET' | 'POST' | 'DELETE' | 'PUT', url: string, body?: object, timeoutMs: number = 3000, @@ -96,7 +97,7 @@ export async function lanHttp( } export async function lanJson( - method: 'GET' | 'POST', + method: 'GET' | 'POST' | 'DELETE' | 'PUT', url: string, body?: object, timeoutMs: number = 3000, diff --git a/src/screens/Browser.tsx b/src/screens/Browser.tsx index a72d4d9..9f7dfb7 100644 --- a/src/screens/Browser.tsx +++ b/src/screens/Browser.tsx @@ -12,6 +12,8 @@ import { import { FileUtils, PluginManager, RattaFileSelector } from 'sn-plugin-lib'; import { StatusDot } from '../StatusDot'; import { baseUrl, loadStreamConfig } from '../storage'; +import { theme } from '../ui/theme'; +import { MenuBar, StatusBar, TitleBar, Win95Button, Win95Frame, Win95InsetPanel } from '../ui/Win95'; import { useConnStatus } from '../useConnStatus'; import type { Entry, EntryKind, SortKey, StreamConfig } from '../types'; import { DEFAULT_STREAM_CONFIG } from '../types'; @@ -210,119 +212,99 @@ export function BrowserScreen({ return ( - - Embed Image + { + onClose(); + PluginManager.closePluginView().catch(() => {}); + }} + /> + load(currentDir) }, + { separator: true, label: '' }, + { label: 'Exit', onPress: () => { onClose(); PluginManager.closePluginView().catch(() => {}); } }, + ], + }, + { + label: 'View', + items: SORT_OPTIONS.map((opt) => ({ + label: `Sort by ${opt.label}${opt.key === sort ? ' •' : ''}`, + onPress: () => setSort(opt.key), + })).concat([ + { label: '', separator: true } as any, + { label: 'Bigger thumbnails', onPress: () => setColumns((c) => clamp(c - 1, MIN_COLUMNS, MAX_COLUMNS)) } as any, + { label: 'Smaller thumbnails', onPress: () => setColumns((c) => clamp(c + 1, MIN_COLUMNS, MAX_COLUMNS)) } as any, + ]), + }, + { + label: 'Tools', + items: [ + { label: 'Live Capture…', onPress: onOpenCapture }, + { label: 'Settings…', onPress: onOpenSettings }, + ], + }, + ]} + /> + + Up + Home + setCurrentDir(INTERNAL_ROOT)}>Internal + + Live… - - Live… - - - Settings - - { - onClose(); - PluginManager.closePluginView().catch(() => {}); - }} - > - Close - - - {status} - - - - Up - - - Home - - setCurrentDir(INTERNAL_ROOT)} - > - Internal - - {currentDir} + + + {currentDir} + - - Sort: - {SORT_OPTIONS.map((opt) => { - const active = opt.key === sort; - return ( - setSort(opt.key)} - style={[styles.chip, active && styles.chipActive]} - > - {opt.label} - - ); - })} - - Size: - setColumns((c) => clamp(c + 1, MIN_COLUMNS, MAX_COLUMNS))} - style={styles.btn} - disabled={columns >= MAX_COLUMNS} - > - = MAX_COLUMNS && styles.btnTxtMuted]}>− - - setColumns((c) => clamp(c - 1, MIN_COLUMNS, MAX_COLUMNS))} - style={styles.btn} - disabled={columns <= MIN_COLUMNS} - > - + - - - Browse… - - load(currentDir)} style={styles.btn}> - Refresh - - + + {sorted.length === 0 ? ( + + Nothing here. + {currentDir} + + File → Open from system… for WebDAV/SD. Tools → Live Capture… to stream from a Mac. + + + ) : ( + it.path} + numColumns={columns} + contentContainerStyle={styles.grid} + renderItem={({ item }) => ( + onPickEntry(item)} + disabled={busy} + > + {item.kind === 'folder' ? ( + + [DIR] + + ) : ( + + )} + {item.name} + + )} + /> + )} + - {sorted.length === 0 ? ( - - Nothing here. - {currentDir} - - Tap “Browse…” for the system picker (WebDAV + SD), “Live…” to stream from a Mac. - - - ) : ( - it.path} - numColumns={columns} - contentContainerStyle={styles.grid} - renderItem={({ item }) => ( - onPickEntry(item)} - disabled={busy} - > - {item.kind === 'folder' ? ( - - 📁 - - ) : ( - - )} - {item.name} - - )} - /> - )} + {status} {busy ? ( - + ) : null} @@ -330,56 +312,43 @@ export function BrowserScreen({ } const styles = StyleSheet.create({ - root: { flex: 1, backgroundColor: '#fff' }, - header: { - flexDirection: 'row', alignItems: 'center', gap: 8, - paddingHorizontal: 16, paddingVertical: 12, - borderBottomWidth: 1, borderBottomColor: '#000', - }, - title: { flex: 1, fontSize: 22, fontWeight: '600', color: '#000' }, - status: { - fontSize: 12, color: '#444', - paddingHorizontal: 16, paddingVertical: 6, - borderBottomWidth: 1, borderBottomColor: '#ddd', - }, - pathBar: { - flexDirection: 'row', alignItems: 'center', gap: 8, - paddingHorizontal: 12, paddingVertical: 8, - borderBottomWidth: 1, borderBottomColor: '#ccc', + root: { flex: 1, backgroundColor: theme.bg }, + toolbar: { + flexDirection: 'row', alignItems: 'center', gap: 4, + paddingHorizontal: 6, paddingVertical: 4, + backgroundColor: theme.bg, + borderBottomWidth: 1, borderBottomColor: theme.shadow, }, - pathTxt: { flex: 1, fontSize: 12, color: '#555' }, - sortBar: { - flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 12, paddingVertical: 8, gap: 8, - borderBottomWidth: 1, borderBottomColor: '#ccc', + pathInsetWrap: { paddingHorizontal: 6, paddingVertical: 4, backgroundColor: theme.bg }, + pathInset: { paddingHorizontal: 6, paddingVertical: 4 }, + pathTxt: { flex: 1, fontFamily: 'VT323', fontSize: 14, color: theme.text }, + listInset: { flex: 1, margin: 6 }, + grid: { padding: 6 }, + tile: { padding: 4, alignItems: 'center' }, + thumb: { + width: '100%', aspectRatio: 1, backgroundColor: theme.bg, + borderTopWidth: 1, borderLeftWidth: 1, + borderRightWidth: 1, borderBottomWidth: 1, + borderTopColor: theme.shadow, borderLeftColor: theme.shadow, + borderRightColor: theme.highlight, borderBottomColor: theme.highlight, }, - sortLabel: { fontSize: 14, color: '#000', marginRight: 4 }, - chip: { paddingHorizontal: 12, paddingVertical: 6, borderWidth: 1, borderColor: '#000' }, - chipActive: { backgroundColor: '#000' }, - chipTxt: { fontSize: 14, color: '#000' }, - chipTxtActive: { color: '#fff' }, - btn: { paddingHorizontal: 14, paddingVertical: 8, borderWidth: 1, borderColor: '#000' }, - btnActive: { backgroundColor: '#000' }, - btnTxt: { fontSize: 14, color: '#000' }, - btnTxtMuted: { color: '#999' }, - btnTxtPrimary: { color: '#fff' }, - grid: { padding: 8 }, - tile: { padding: 6, alignItems: 'center' }, - thumb: { width: '100%', aspectRatio: 1, backgroundColor: '#eee' }, folderThumb: { - width: '100%', aspectRatio: 1, backgroundColor: '#f4f4f4', - borderWidth: 1, borderColor: '#000', + width: '100%', aspectRatio: 1, backgroundColor: theme.bg, + borderTopWidth: 1, borderLeftWidth: 1, + borderRightWidth: 1, borderBottomWidth: 1, + borderTopColor: theme.highlight, borderLeftColor: theme.highlight, + borderRightColor: theme.dark, borderBottomColor: theme.dark, alignItems: 'center', justifyContent: 'center', }, - folderIcon: { fontSize: 40 }, - tileName: { marginTop: 4, fontSize: 12, color: '#000', textAlign: 'center' }, + folderIcon: { fontFamily: 'VT323', fontSize: 22, color: theme.text }, + tileName: { marginTop: 4, fontFamily: 'VT323', fontSize: 14, color: theme.text, textAlign: 'center' }, center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }, - empty: { fontSize: 16, color: '#000', marginBottom: 4 }, - emptySub: { fontSize: 13, color: '#444', textAlign: 'center', marginBottom: 12 }, - emptyHint: { fontSize: 12, color: '#666', textAlign: 'center' }, + empty: { fontFamily: 'VT323', fontSize: 18, color: theme.text, marginBottom: 4 }, + emptySub: { fontFamily: 'VT323', fontSize: 14, color: theme.textMuted, textAlign: 'center', marginBottom: 12 }, + emptyHint: { fontFamily: 'VT323', fontSize: 14, color: theme.textMuted, textAlign: 'center' }, overlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, - backgroundColor: 'rgba(0,0,0,0.4)', + backgroundColor: 'rgba(192,192,192,0.6)', alignItems: 'center', justifyContent: 'center', }, }); diff --git a/src/screens/Capture.tsx b/src/screens/Capture.tsx index 83c35a7..f240cef 100644 --- a/src/screens/Capture.tsx +++ b/src/screens/Capture.tsx @@ -16,6 +16,8 @@ import { StatusDot } from '../StatusDot'; import { downloadAndBake, lanHttp, lanJson, lanPostFile } from '../imageProcessor'; import { insertAndTrack, replaceInPlace } from '../embedTracker'; import { baseUrl, saveStreamConfig } from '../storage'; +import { theme } from '../ui/theme'; +import { MenuBar, StatusBar, TitleBar, Win95Button, Win95InsetPanel } from '../ui/Win95'; import { useConnStatus } from '../useConnStatus'; import { Adjustments, @@ -307,87 +309,84 @@ export function CaptureScreen({ return ( - - - Back - - Live Capture + + fetchOnce(), disabled: !url }, + { separator: true, label: '' }, + { label: 'Check status', onPress: onCheckStatus, disabled: !url }, + { label: 'Close (back to canvas)', onPress: onCloseToCanvas }, + { label: 'Back to Browser', onPress: onBackToBrowser }, + ], + }, + { + label: 'Source', + items: [ + { label: 'Pick screen / window / region…', onPress: onOpenSourcePicker, disabled: !url }, + ], + }, + { + label: 'Image', + items: [ + { label: 'Remove background (still only)', onPress: onRemoveBg, disabled: !framePath || capturing }, + { separator: true, label: '' }, + { label: 'Settings…', onPress: onOpenSettings }, + ], + }, + ]} + /> + + + + {url || '(no server set)'} · {intervalSec.toFixed(1)}s · {Math.round(resolutionMul * 100)}% ·{' '} + {lastFrameTs ? `frame ${ageSec}s ago` : 'no frame yet'}{track ? ' · EMBED' : ''} + - - Source - - - Settings - - - {url || '(no server set)'} · interval {intervalSec.toFixed(1)}s ·{' '} - {lastFrameTs ? `last frame ${ageSec}s ago` : 'no frame yet'} - {track ? ' · embedded' : ''} - - - + {sourceUri ? ( ) : ( - {capturing ? 'waiting for first frame…' : 'tap Start to begin streaming'} + {capturing ? 'waiting for first frame…' : 'STREAM → Start to begin'} )} - + - - - {capturing ? 'Pause' : 'Start'} - - - fetchOnce()} disabled={busy || !url}> - Pull once - - - Remove BG - + + {capturing ? 'Pause' : 'Start'} + + fetchOnce()} disabled={busy || !url}>Pull + Rm BG - changeInterval(-INTERVAL_STEP)} disabled={busy}> - - - {intervalSec.toFixed(1)}s - changeInterval(+INTERVAL_STEP)} disabled={busy}> - + - + Interval + changeInterval(-INTERVAL_STEP)} disabled={busy}>− + {intervalSec.toFixed(1)}s + changeInterval(+INTERVAL_STEP)} disabled={busy}>+ - Res - changeResolutionMul(-0.1)} disabled={busy}> - - - {Math.round(resolutionMul * 100)}% - changeResolutionMul(+0.1)} disabled={busy}> - + - + Source… + Status - - Status - + Res + changeResolutionMul(-0.1)} disabled={busy}>− + {Math.round(resolutionMul * 100)}% + changeResolutionMul(+0.1)} disabled={busy}>+ - + {logs.length === 0 ? ( - logs will appear here… + C:\>_ logs will appear here… ) : ( logs.map((e, i) => ( @@ -396,40 +395,21 @@ export function CaptureScreen({ )) )} - + - - Close - - - Insert - - - Replace - - - - {track ? 'Replace & Close' : 'Insert & Close'} - - + Close + + Insert + Replace + + {track ? 'Replace & Close' : 'Insert & Close'} + {busy ? ( - + ) : null} @@ -437,59 +417,45 @@ export function CaptureScreen({ } const styles = StyleSheet.create({ - root: { flex: 1, backgroundColor: '#fff' }, - header: { - flexDirection: 'row', alignItems: 'center', gap: 12, - paddingHorizontal: 16, paddingVertical: 12, - borderBottomWidth: 1, borderBottomColor: '#000', - }, - title: { flex: 1, fontSize: 22, fontWeight: '600', color: '#000' }, - status: { - fontSize: 12, color: '#444', - paddingHorizontal: 16, paddingVertical: 6, - borderBottomWidth: 1, borderBottomColor: '#ddd', + root: { flex: 1, backgroundColor: theme.bg }, + statusStrip: { + flexDirection: 'row', alignItems: 'center', + backgroundColor: theme.bg, + paddingHorizontal: 8, paddingVertical: 2, }, - btn: { paddingHorizontal: 14, paddingVertical: 8, borderWidth: 1, borderColor: '#000' }, - btnActive: { backgroundColor: '#000' }, - btnTxt: { fontSize: 14, color: '#000' }, - btnTxtPrimary: { color: '#fff' }, - btnTxtMuted: { color: '#999' }, + statusStripTxt: { flex: 1, fontFamily: 'VT323', fontSize: 14, color: theme.text }, previewArea: { - flex: 1, backgroundColor: '#fff', margin: 12, - borderWidth: 1, borderColor: '#000', + flex: 1, margin: 6, alignItems: 'center', justifyContent: 'center', overflow: 'hidden', }, previewImg: { width: '100%', height: '100%' }, - placeholder: { fontSize: 14, color: '#666' }, + placeholder: { fontFamily: 'VT323', fontSize: 18, color: theme.textMuted, padding: 16 }, controlBar: { - flexDirection: 'row', alignItems: 'center', gap: 8, - paddingHorizontal: 12, paddingVertical: 8, - borderTopWidth: 1, borderTopColor: '#ccc', + flexDirection: 'row', alignItems: 'center', gap: 4, + paddingHorizontal: 6, paddingVertical: 3, + backgroundColor: theme.bg, + }, + dim: { fontFamily: 'VT323', fontSize: 14, color: theme.text, marginHorizontal: 4 }, + fieldVal: { + fontFamily: 'VT323', fontSize: 14, color: theme.text, + minWidth: 44, textAlign: 'center', }, - intervalLabel: { fontSize: 14, color: '#000', minWidth: 48, textAlign: 'center', fontVariant: ['tabular-nums'] }, logBox: { - height: 110, marginHorizontal: 12, marginBottom: 8, - borderWidth: 1, borderColor: '#bbb', backgroundColor: '#fafafa', + height: 100, marginHorizontal: 6, marginVertical: 4, + paddingHorizontal: 4, }, - logScroll: { padding: 6 }, - logEmpty: { fontSize: 11, color: '#888' }, - logLine: { fontSize: 11, color: '#222', fontFamily: 'monospace' }, + logScroll: { paddingVertical: 4 }, + logEmpty: { fontFamily: 'VT323', fontSize: 14, color: theme.shadow }, + logLine: { fontFamily: 'VT323', fontSize: 14, color: theme.text }, actionRow: { - flexDirection: 'row', gap: 12, - paddingHorizontal: 16, paddingVertical: 12, - borderTopWidth: 1, borderTopColor: '#ccc', - }, - actionBtn: { - flex: 1, paddingVertical: 12, - borderWidth: 1, borderColor: '#000', - alignItems: 'center', justifyContent: 'center', + flexDirection: 'row', gap: 6, + paddingHorizontal: 6, paddingVertical: 6, + backgroundColor: theme.bg, alignItems: 'center', }, - actionBtnPrimary: { backgroundColor: '#000' }, - actionBtnDisabled: { borderColor: '#ccc' }, overlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, - backgroundColor: 'rgba(0,0,0,0.4)', + backgroundColor: 'rgba(192,192,192,0.6)', alignItems: 'center', justifyContent: 'center', }, }); diff --git a/src/screens/DropInbox.tsx b/src/screens/DropInbox.tsx new file mode 100644 index 0000000..0e1ec4c --- /dev/null +++ b/src/screens/DropInbox.tsx @@ -0,0 +1,178 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + ActivityIndicator, + FlatList, + Image, + Pressable, + SafeAreaView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { PluginManager } from 'sn-plugin-lib'; +import { insertAndTrack } from '../embedTracker'; +import { downloadAndBake, lanHttp, lanJson } from '../imageProcessor'; +import { baseUrl, loadStreamConfig } from '../storage'; +import { theme } from '../ui/theme'; +import { StatusBar, TitleBar, Win95Button, Win95InsetPanel } from '../ui/Win95'; +import { DEFAULT_ADJUSTMENTS, DEFAULT_STREAM_CONFIG } from '../types'; + +// "Drop Inbox" — files dropped (or AirDropped) into ~/EmbedImage/Drop on +// the Mac appear here, newest first. Tap to insert; the Mac then moves +// the file out of the watch folder so it won't show up again. + +type DropItem = { name: string; size: number; mtime: number }; + +export function DropInbox({ onClose }: { onClose: () => void }): React.JSX.Element { + const [url, setUrl] = useState(''); + const [items, setItems] = useState([]); + const [folder, setFolder] = useState(''); + const [status, setStatus] = useState('loading…'); + const [busy, setBusy] = useState(false); + const [thumbs, setThumbs] = useState>({}); + + const refresh = useCallback(async (u: string) => { + if (!u) return; + setStatus('refreshing…'); + try { + const res: any = await lanJson('GET', `${u}/drop/list`, undefined, 3000); + const list: DropItem[] = Array.isArray(res?.items) ? res.items : []; + setItems(list); + setFolder(String(res?.folder ?? '')); + setStatus(`${list.length} item${list.length === 1 ? '' : 's'}`); + // Pre-fetch small thumbnails for the visible list. + const newThumbs: Record = {}; + for (const it of list.slice(0, 24)) { + try { + const path = await downloadAndBake( + `${u}/drop/file?name=${encodeURIComponent(it.name)}`, + DEFAULT_ADJUSTMENTS, + 200, // small thumb + 8000, + ); + newThumbs[it.name] = 'file://' + path; + } catch { + // skip; show name only + } + } + setThumbs(newThumbs); + } catch (e: any) { + setStatus(`failed: ${e?.message ?? e}`); + } + }, []); + + useEffect(() => { + (async () => { + const cfg = await loadStreamConfig(); + const u = baseUrl(cfg); + setUrl(u); + if (u) refresh(u); + else setStatus('no server configured'); + })(); + }, [refresh]); + + const onInsert = useCallback(async (it: DropItem) => { + if (busy) return; + setBusy(true); + setStatus(`embedding ${it.name}…`); + try { + const path = await downloadAndBake( + `${url}/drop/file?name=${encodeURIComponent(it.name)}`, + DEFAULT_ADJUSTMENTS, + 0, + 15000, + ); + await insertAndTrack(path); + // Consume on the Mac (moves it to .consumed/) and refresh. + await lanHttp('POST', `${url}/drop/consume`, { name: it.name }, 3000).catch(() => {}); + await PluginManager.closePluginView().catch(() => {}); + } catch (e: any) { + setStatus(`insert failed: ${e?.message ?? e}`); + setBusy(false); + } + }, [busy, url]); + + return ( + + + + {folder || '(folder unknown)'} + refresh(url)} disabled={busy || !url}>Refresh + + + + {items.length === 0 ? ( + + Inbox is empty. + + Drop or AirDrop image files into:{'\n'}{folder || '~/EmbedImage/Drop'}{'\n\n'} + They'll show up here on the next Refresh. + + + ) : ( + it.name} + numColumns={3} + contentContainerStyle={styles.grid} + renderItem={({ item }) => { + const uri = thumbs[item.name]; + return ( + onInsert(item)} disabled={busy}> + + {uri ? ( + + ) : ( + [IMG] + )} + + {item.name} + + ); + }} + /> + )} + + + {status} + + {busy ? ( + + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: theme.bg }, + subhead: { + flexDirection: 'row', alignItems: 'center', gap: 8, + paddingHorizontal: 6, paddingVertical: 4, + backgroundColor: theme.bg, + }, + folder: { flex: 1, fontFamily: 'VT323', fontSize: 14, color: theme.text }, + listInset: { flex: 1, margin: 6 }, + grid: { padding: 6 }, + tile: { width: '33.333%', padding: 4 }, + tileImgWrap: { + width: '100%', aspectRatio: 1, backgroundColor: theme.bg, + borderTopWidth: 1, borderLeftWidth: 1, + borderRightWidth: 1, borderBottomWidth: 1, + borderTopColor: theme.shadow, borderLeftColor: theme.shadow, + borderRightColor: theme.highlight, borderBottomColor: theme.highlight, + alignItems: 'center', justifyContent: 'center', + }, + tileImg: { width: '100%', height: '100%' }, + tilePlaceholder: { fontFamily: 'VT323', fontSize: 14, color: theme.shadow }, + tileName: { marginTop: 4, fontFamily: 'VT323', fontSize: 14, color: theme.text, textAlign: 'center' }, + empty: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }, + emptyTitle: { fontFamily: 'VT323', fontSize: 18, color: theme.text, marginBottom: 6 }, + emptyHint: { fontFamily: 'VT323', fontSize: 14, color: theme.textMuted, textAlign: 'center' }, + overlay: { + position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, + backgroundColor: 'rgba(192,192,192,0.6)', + alignItems: 'center', justifyContent: 'center', + }, +}); diff --git a/src/screens/Preview.tsx b/src/screens/Preview.tsx index 6d13092..c16b9b1 100644 --- a/src/screens/Preview.tsx +++ b/src/screens/Preview.tsx @@ -14,6 +14,8 @@ import { AdjustmentPanel } from '../AdjustmentPanel'; import { StatusDot } from '../StatusDot'; import { adjustmentsAreDefault, bakeFile, lanPostFile } from '../imageProcessor'; import { baseUrl, loadStreamConfig } from '../storage'; +import { theme } from '../ui/theme'; +import { StatusBar, TitleBar, Win95Button, Win95InsetPanel } from '../ui/Win95'; import { useConnStatus } from '../useConnStatus'; import { Adjustments, DEFAULT_ADJUSTMENTS, DEFAULT_STREAM_CONFIG, Entry, StreamConfig } from '../types'; @@ -131,46 +133,31 @@ export function PreviewScreen({ return ( - - - Back - - {entry.name} - {previewing ? : null} + + + {status} + {previewing ? : null} - {status} - - + - + - - Cancel - - - Remove BG - - - Insert - + Cancel + + Remove BG + Insert + {baseUrl(streamCfg) || 'no Mac server set'} + {busy ? ( - + ) : null} @@ -178,42 +165,27 @@ export function PreviewScreen({ } const styles = StyleSheet.create({ - root: { flex: 1, backgroundColor: '#fff' }, - header: { - flexDirection: 'row', alignItems: 'center', gap: 12, - paddingHorizontal: 16, paddingVertical: 12, - borderBottomWidth: 1, borderBottomColor: '#000', + root: { flex: 1, backgroundColor: theme.bg }, + statusStrip: { + flexDirection: 'row', alignItems: 'center', gap: 8, + paddingHorizontal: 8, paddingVertical: 2, + backgroundColor: theme.bg, }, - title: { flex: 1, fontSize: 22, fontWeight: '600', color: '#000' }, - status: { - fontSize: 12, color: '#444', - paddingHorizontal: 16, paddingVertical: 6, - borderBottomWidth: 1, borderBottomColor: '#ddd', - }, - btn: { paddingHorizontal: 14, paddingVertical: 8, borderWidth: 1, borderColor: '#000' }, - btnTxt: { fontSize: 14, color: '#000' }, - btnTxtPrimary: { color: '#fff' }, + statusTxt: { flex: 1, fontFamily: 'VT323', fontSize: 14, color: theme.text }, previewArea: { - flex: 1, backgroundColor: '#fff', margin: 12, - borderWidth: 1, borderColor: '#000', + flex: 1, margin: 6, alignItems: 'center', justifyContent: 'center', overflow: 'hidden', }, previewImg: { width: '100%', height: '100%' }, actionRow: { - flexDirection: 'row', gap: 12, - paddingHorizontal: 16, paddingVertical: 12, - borderTopWidth: 1, borderTopColor: '#ccc', - }, - actionBtn: { - flex: 1, paddingVertical: 12, - borderWidth: 1, borderColor: '#000', - alignItems: 'center', justifyContent: 'center', + flexDirection: 'row', gap: 6, + paddingHorizontal: 6, paddingVertical: 6, + alignItems: 'center', backgroundColor: theme.bg, }, - actionBtnPrimary: { backgroundColor: '#000' }, overlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, - backgroundColor: 'rgba(0,0,0,0.4)', + backgroundColor: 'rgba(192,192,192,0.6)', alignItems: 'center', justifyContent: 'center', }, }); diff --git a/src/screens/Refresh.tsx b/src/screens/Refresh.tsx index cf98079..5e1c1e6 100644 --- a/src/screens/Refresh.tsx +++ b/src/screens/Refresh.tsx @@ -1,16 +1,29 @@ -import React, { useEffect, useState } from 'react'; -import { ActivityIndicator, SafeAreaView, StyleSheet, Text } from 'react-native'; +import React, { useEffect, useRef, useState } from 'react'; +import { Animated, Easing, SafeAreaView, StyleSheet, Text, View } from 'react-native'; import { PluginManager } from 'sn-plugin-lib'; import { replaceInPlace, insertAndTrack } from '../embedTracker'; import { downloadAndBake } from '../imageProcessor'; import { baseUrl, loadEmbedTrack, loadStreamConfig } from '../storage'; +import { theme } from '../ui/theme'; +import { TitleBar, Win95Frame, Win95InsetPanel } from '../ui/Win95'; import { DEFAULT_ADJUSTMENTS } from '../types'; -// Headless-ish: opens briefly when the "Refresh Embed" sidebar button is -// tapped, fetches the latest frame from the configured Mac server, and -// replaces (or inserts) the embedded image, then closes the plugin view. +const PROGRESS_SEGMENTS = 16; + export function RefreshScreen(): React.JSX.Element { - const [status, setStatus] = useState('refreshing…'); + const [status, setStatus] = useState('Refreshing embed…'); + const tick = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.loop( + Animated.timing(tick, { + toValue: PROGRESS_SEGMENTS, + duration: 1600, + easing: Easing.linear, + useNativeDriver: false, + }), + ).start(); + }, [tick]); useEffect(() => { let cancelled = false; @@ -19,7 +32,7 @@ export function RefreshScreen(): React.JSX.Element { const cfg = await loadStreamConfig(); const url = baseUrl(cfg); if (!url) { - setStatus('no server configured — open Embed Image → Settings'); + setStatus('No server configured.\nOpen Embed Image → Settings.'); setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2500); return; } @@ -34,7 +47,7 @@ export function RefreshScreen(): React.JSX.Element { if (cancelled) return; await PluginManager.closePluginView().catch(() => {}); } catch (e: any) { - setStatus(`failed: ${e?.message ?? e}`); + setStatus(`FAILED: ${e?.message ?? e}`); setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2500); } })(); @@ -43,15 +56,50 @@ export function RefreshScreen(): React.JSX.Element { }; }, []); + const segIndex = tick.interpolate({ + inputRange: [0, PROGRESS_SEGMENTS], + outputRange: [0, PROGRESS_SEGMENTS], + }); + return ( - - {status} + + + + Refreshing embed… + + + {Array.from({ length: PROGRESS_SEGMENTS }).map((_, i) => ( + + ))} + + + {status} + + ); } const styles = StyleSheet.create({ - root: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#fff', gap: 16 }, - txt: { fontSize: 14, color: '#000' }, + root: { flex: 1, backgroundColor: theme.bg }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16 }, + dialog: { padding: 16, minWidth: 320, gap: 12 }, + heading: { fontFamily: 'VT323', fontSize: 22, color: theme.text }, + progressInset: { padding: 4 }, + progressRow: { flexDirection: 'row', gap: 2 }, + segment: { flex: 1, height: 16, backgroundColor: theme.titleBg }, + msg: { fontFamily: 'VT323', fontSize: 16, color: theme.textMuted, textAlign: 'center' }, }); diff --git a/src/screens/SendLasso.tsx b/src/screens/SendLasso.tsx new file mode 100644 index 0000000..cb83925 --- /dev/null +++ b/src/screens/SendLasso.tsx @@ -0,0 +1,97 @@ +import React, { useEffect, useState } from 'react'; +import { + ActivityIndicator, + Image, + SafeAreaView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { PluginCommAPI, PluginManager } from 'sn-plugin-lib'; +import { lanPostFile } from '../imageProcessor'; +import { baseUrl, loadStreamConfig } from '../storage'; +import { theme } from '../ui/theme'; +import { StatusBar, TitleBar, Win95Button, Win95Frame, Win95InsetPanel } from '../ui/Win95'; + +// Headless-ish: triggered by the "Send Lasso to Mac" sidebar button. +// Generates a PNG of the currently-lassoed elements via the SDK, POSTs +// it to /sketch on the Mac, then closes. Mac stashes it in ~/EmbedImage/ +// Sketches with a timestamped filename. + +export function SendLasso({ onClose }: { onClose: () => void }): React.JSX.Element { + const [status, setStatus] = useState('Capturing lasso…'); + const [previewUri, setPreviewUri] = useState(null); + const [done, setDone] = useState(false); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const cfg = await loadStreamConfig(); + const url = baseUrl(cfg); + if (!url) { + setStatus('No Mac server set. Settings → Mac Capture Server.'); + return; + } + // Ask the SDK to write the lasso preview to a file we control. + const ts = Date.now(); + // The plugin's sandbox cache is the safe place to write. + const tmpPath = `/data/user/0/com.ratta.supernote.pluginhost/files/plugins/embedimagev0001a/lasso_${ts}.png`; + const res: any = await PluginCommAPI.generateLassoPreview(tmpPath); + if (!res || res.success === false) { + setStatus(`No lasso content. Lasso something first, then tap again.`); + return; + } + const resultPath: string = + typeof res?.result === 'string' ? res.result : + (res?.result?.path ?? tmpPath); + setPreviewUri('file://' + resultPath); + setStatus('Uploading to Mac…'); + const name = `manta_${new Date().toISOString().replace(/[:.]/g, '-')}.png`; + await lanPostFile(`${url}/sketch?name=${encodeURIComponent(name)}`, resultPath, 'image/png', 30000); + if (cancelled) return; + setStatus(`Sent to Mac as ${name}`); + setDone(true); + } catch (e: any) { + setStatus(`Failed: ${e?.message ?? e}`); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + return ( + + + + + Sending lasso to Mac… + + {previewUri ? ( + + ) : ( + + )} + + {status} + {done ? ( + PluginManager.closePluginView().catch(() => {})} primary> + OK + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: theme.bg }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 12 }, + dialog: { padding: 12, minWidth: 320, gap: 12 }, + heading: { fontFamily: 'VT323', fontSize: 22, color: theme.text }, + previewBox: { height: 220, alignItems: 'center', justifyContent: 'center' }, + preview: { width: '100%', height: '100%' }, + msg: { fontFamily: 'VT323', fontSize: 16, color: theme.textMuted, textAlign: 'center' }, +}); diff --git a/src/screens/Settings.tsx b/src/screens/Settings.tsx index a41a588..c28a778 100644 --- a/src/screens/Settings.tsx +++ b/src/screens/Settings.tsx @@ -1,8 +1,8 @@ import React, { useCallback, useEffect, useState } from 'react'; import { ActivityIndicator, - Pressable, SafeAreaView, + ScrollView, StyleSheet, Text, TextInput, @@ -10,6 +10,8 @@ import { } from 'react-native'; import { baseUrl, loadStreamConfig, saveStreamConfig } from '../storage'; import { lanJson } from '../imageProcessor'; +import { theme } from '../ui/theme'; +import { StatusBar, TitleBar, Win95Button, Win95InsetPanel } from '../ui/Win95'; import { DEFAULT_STREAM_CONFIG, StreamConfig } from '../types'; function clamp(n: number, lo: number, hi: number): number { @@ -81,132 +83,110 @@ export function SettingsScreen({ return ( - - - Back - - Settings - + + + + Mac Capture Server + + IP shown by the Mac app + its port. Plugin fetches frames from + http://<host>:<port>/frame. + - {status} + + + - - Mac Capture Server - - Set the IP shown by the Mac app, plus its port (default 9000). The plugin will fetch - frames from http://<host>:<port>/frame. - + + + - - Host (Mac IP) - - + + + 0.2 .. 60. 1.0 = 1 fps (good for e-ink). + - - Port - - + + + + 0.1 .. 1.0. Mac downscales each frame by this factor before sending. + + - - Frame interval (seconds) - - 0.2 .. 60 seconds. 1.0 = 1 fps (recommended for e-ink). + + Test connection + + Save + + - - Resolution multiplier - - - 0.1 .. 1.0. Mac downscales each frame by this factor before sending. - Lower = less bandwidth + faster Mac → Manta round-trip. - - - - - - Test connection - - - Save - - - + {status} {busy ? ( - + ) : null} ); } +function Field({ label, children }: { label: string; children: React.ReactNode }): React.JSX.Element { + return ( + + {label} + {children} + + ); +} + const styles = StyleSheet.create({ - root: { flex: 1, backgroundColor: '#fff' }, - header: { - flexDirection: 'row', alignItems: 'center', gap: 12, - paddingHorizontal: 16, paddingVertical: 12, - borderBottomWidth: 1, borderBottomColor: '#000', - }, - title: { flex: 1, fontSize: 22, fontWeight: '600', color: '#000' }, - status: { - fontSize: 12, color: '#444', - paddingHorizontal: 16, paddingVertical: 6, - borderBottomWidth: 1, borderBottomColor: '#ddd', - }, - btn: { paddingHorizontal: 14, paddingVertical: 8, borderWidth: 1, borderColor: '#000' }, - btnTxt: { fontSize: 14, color: '#000' }, - btnTxtPrimary: { color: '#fff' }, - body: { padding: 16, gap: 12 }, - section: { fontSize: 16, fontWeight: '600', color: '#000', marginBottom: 4 }, - hint: { fontSize: 12, color: '#666' }, + root: { flex: 1, backgroundColor: theme.bg }, + body: { padding: 12, gap: 10 }, + section: { fontFamily: 'VT323', fontSize: 20, color: theme.text, marginBottom: 2 }, + hint: { fontFamily: 'VT323', fontSize: 14, color: theme.textMuted }, field: { gap: 4 }, - label: { fontSize: 13, color: '#000' }, + label: { fontFamily: 'VT323', fontSize: 16, color: theme.text }, + inputWrap: { padding: 0 }, input: { - borderWidth: 1, borderColor: '#000', - paddingHorizontal: 12, paddingVertical: 8, - fontSize: 14, color: '#000', - }, - row: { flexDirection: 'row', gap: 12, marginTop: 12 }, - actionBtn: { - flex: 1, paddingVertical: 12, - borderWidth: 1, borderColor: '#000', - alignItems: 'center', justifyContent: 'center', + fontFamily: 'VT323', + paddingHorizontal: 8, paddingVertical: 6, + fontSize: 16, color: theme.text, }, - actionBtnPrimary: { backgroundColor: '#000' }, + row: { flexDirection: 'row', gap: 8, marginTop: 8, alignItems: 'center' }, overlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, - backgroundColor: 'rgba(0,0,0,0.4)', + backgroundColor: 'rgba(192,192,192,0.6)', alignItems: 'center', justifyContent: 'center', }, }); diff --git a/src/screens/SourcePicker.tsx b/src/screens/SourcePicker.tsx index 1199dff..05c0afd 100644 --- a/src/screens/SourcePicker.tsx +++ b/src/screens/SourcePicker.tsx @@ -1,6 +1,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, + Animated, + Easing, FlatList, GestureResponderEvent, Image, @@ -11,14 +13,10 @@ import { View, } from 'react-native'; import { lanHttp, lanJson } from '../imageProcessor'; +import { theme } from '../ui/theme'; +import { StatusBar, TitleBar, Win95Button, Win95Frame, Win95InsetPanel } from '../ui/Win95'; import { MacWindow, Region } from '../types'; -// Lets the user pick a capture source (full screen / window / region) -// directly from the Manta. For "region" we pull a downscaled snapshot of -// the Mac primary monitor and let the user drag a rectangle on the -// touchscreen; coordinates are mapped back to Mac pixels via the -// X-Mon-* / X-Scale headers returned by /preview-shot. - type Mode = 'menu' | 'window' | 'region'; export function SourcePicker({ @@ -77,13 +75,12 @@ export function SourcePicker({ const openRegion = useCallback(async () => { setMode('region'); setBusy(true); - setStatus('grabbing screenshot from Mac…'); + setStatus('TUNING… grabbing screen from Mac'); try { const { downloadAndBake } = await import('../imageProcessor'); const { DEFAULT_ADJUSTMENTS } = await import('../types'); - const previewMax = 700; const path = await downloadAndBake( - `${baseUrl}/preview-shot?max=${previewMax}`, + `${baseUrl}/preview-shot?max=700`, DEFAULT_ADJUSTMENTS, 0, 8000, @@ -92,8 +89,7 @@ export function SourcePicker({ const mon = st?.monitor ?? { left: 0, top: 0, width: 0, height: 0 }; setShotUri('file://' + path); Image.getSize('file://' + path, (pw, ph) => { - const monLong = Math.max(mon.width || 0, mon.height || 0) || Math.max(pw, ph); - const scale = monLong > 0 ? pw / (mon.width || pw) : 1.0; + const scale = mon.width ? pw / mon.width : 1.0; setShotInfo({ monLeft: mon.left || 0, monTop: mon.top || 0, @@ -103,10 +99,10 @@ export function SourcePicker({ previewW: pw, previewH: ph, }); - setStatus(`drag a region · mac ${mon.width}×${mon.height}`); - }, () => setStatus('failed to read screenshot')); + setStatus(`SIGNAL OK — ${mon.width}×${mon.height}`); + }, () => setStatus('NO SIGNAL')); } catch (e: any) { - setStatus(`failed: ${e?.message ?? e}`); + setStatus(`FAULT: ${e?.message ?? e}`); } finally { setBusy(false); } @@ -126,9 +122,7 @@ export function SourcePicker({ const { locationX, locationY } = e.nativeEvent; setDragEnd({ x: locationX, y: locationY }); }, []); - const onTouchEnd = useCallback(() => { - // no-op; selection committed by "Use selection" button. - }, []); + const onTouchEnd = useCallback(() => {}, []); const selectionRect = useMemo(() => { if (!dragStart || !dragEnd) return null; @@ -152,28 +146,25 @@ export function SourcePicker({ postSource({ source: 'region', region }, `Region ${region.w}×${region.h}`); }, [selectionRect, shotInfo, postSource]); + // ---- Menu mode (Win95 chrome) ---- if (mode === 'menu') { return ( - - - Back - - Mac source - + - - Full screen - - - Window… - - - Region (drag on preview) - - {!!status && {status}} + + Capture Source + Choose what the Mac sends. + + Full Screen + + Window… + + Region (drag on CRT) + - {busy ? : null} + {status || 'Ready.'} + {busy ? : null} ); } @@ -181,132 +172,247 @@ export function SourcePicker({ if (mode === 'window') { return ( - - setMode('menu')}> - Back - - Pick window - - {status} - String(w.id)} - renderItem={({ item }) => ( - postSource({ source: 'window', window_id: item.id }, `${item.owner}`)} - disabled={busy} - > - {item.owner || '(unknown)'} - - {item.title || '(no title)'} · {item.w}×{item.h} - - - )} - /> - {busy ? : null} + setMode('menu')} /> + + String(w.id)} + renderItem={({ item }) => ( + [styles.windowRow, pressed && styles.windowRowPressed]} + onPress={() => postSource({ source: 'window', window_id: item.id }, `${item.owner}`)} + disabled={busy} + > + {item.owner || '(unknown)'} + + {item.title || '(no title)'} · {item.w}×{item.h} + + + )} + /> + + {status} + {busy ? : null} ); } - // region mode + // ---- Region mode: CRT screensaver aesthetic ---- return ( - - setMode('menu')}> - Back - - Drag region - - {status} - - {shotUri && previewLayout ? ( - true} - onMoveShouldSetResponder={() => true} - onResponderGrant={onTouchStart} - onResponderMove={onTouchMove} - onResponderRelease={onTouchEnd} - > - - {selectionRect ? ( - setMode('menu')} /> + + + {shotUri && previewLayout ? ( + true} + onMoveShouldSetResponder={() => true} + onResponderGrant={onTouchStart} + onResponderMove={onTouchMove} + onResponderRelease={onTouchEnd} + > + - ) : null} - - ) : ( - - )} + + + {selectionRect ? ( + + + {Math.round(selectionRect.w / (shotInfo?.scale ?? 1))}× + {Math.round(selectionRect.h / (shotInfo?.scale ?? 1))} + + + ) : null} + SOURCE-1 + {status} + + ) : ( + + + NO SIGNAL + {status} + + )} + - - { setDragStart(null); setDragEnd(null); }}> - Clear - + + { setDragStart(null); setDragEnd(null); }}>Clear - - Use selection - + + Use Selection + - {busy ? : null} + {status} + {busy ? : null} ); } +// ---- CRT cosmetic components ---- + +function CrtBezel({ children }: { children: React.ReactNode }): React.JSX.Element { + return ( + + + {children} + + + + + + + + + ); +} + +function Scanlines(): React.JSX.Element { + // Horizontal scanline overlay — done with a column of thin transparent + // strips. Looks like an old CRT under e-ink rendering. + const rows = 80; + return ( + + {Array.from({ length: rows }).map((_, i) => ( + + ))} + + ); +} + +function CrtPhosphorGlow(): React.JSX.Element { + const flicker = React.useRef(new Animated.Value(0.85)).current; + useEffect(() => { + Animated.loop( + Animated.sequence([ + Animated.timing(flicker, { toValue: 1.0, duration: 200, easing: Easing.linear, useNativeDriver: false }), + Animated.timing(flicker, { toValue: 0.85, duration: 240, easing: Easing.linear, useNativeDriver: false }), + Animated.timing(flicker, { toValue: 0.95, duration: 90, easing: Easing.linear, useNativeDriver: false }), + ]), + ).start(); + }, [flicker]); + return ; +} + +function CrtCornerTag({ children }: { children: React.ReactNode }): React.JSX.Element { + return {children}; +} + +function CrtBottomTag({ children }: { children: React.ReactNode }): React.JSX.Element { + return {children}; +} + +function Overlay(): React.JSX.Element { + return ; +} + +const PHOSPHOR = '#5fff7f'; + const styles = StyleSheet.create({ - root: { flex: 1, backgroundColor: '#fff' }, - header: { - flexDirection: 'row', alignItems: 'center', gap: 12, - paddingHorizontal: 16, paddingVertical: 12, - borderBottomWidth: 1, borderBottomColor: '#000', + root: { flex: 1, backgroundColor: theme.bg }, + body: { flex: 1, padding: 16, alignItems: 'center' }, + menuCard: { padding: 16, minWidth: 280 }, + menuTitle: { fontFamily: 'VT323', fontSize: 22, color: theme.text }, + menuHint: { fontFamily: 'VT323', fontSize: 14, color: theme.textMuted }, + windowList: { flex: 1, margin: 6 }, + windowRow: { paddingVertical: 8, paddingHorizontal: 12, borderBottomWidth: 1, borderBottomColor: theme.shadow }, + windowRowPressed: { backgroundColor: theme.selBg }, + windowOwner: { fontFamily: 'VT323', fontSize: 16, color: theme.text }, + windowTitle: { fontFamily: 'VT323', fontSize: 14, color: theme.textMuted, marginTop: 2 }, + + // ---- CRT styles ---- + crtWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 8, backgroundColor: theme.bg }, + crtOuter: { alignItems: 'center' }, + crtFrame: { + padding: 18, + backgroundColor: '#dcdcc8', // beige '90s monitor plastic + borderTopLeftRadius: 14, borderTopRightRadius: 14, + borderBottomLeftRadius: 6, borderBottomRightRadius: 6, + borderTopWidth: 2, borderLeftWidth: 2, + borderRightWidth: 2, borderBottomWidth: 2, + borderTopColor: '#fff', borderLeftColor: '#fff', + borderRightColor: '#808070', borderBottomColor: '#808070', + }, + crtInner: { + padding: 6, + backgroundColor: '#080808', + borderRadius: 8, + borderWidth: 2, borderColor: '#202020', }, - title: { flex: 1, fontSize: 22, fontWeight: '600', color: '#000' }, - status: { fontSize: 12, color: '#444', paddingHorizontal: 16, paddingVertical: 6 }, - btn: { paddingHorizontal: 14, paddingVertical: 8, borderWidth: 1, borderColor: '#000' }, - btnActive: { backgroundColor: '#000' }, - btnTxt: { fontSize: 14, color: '#000' }, - btnTxtPrimary: { color: '#fff' }, - body: { padding: 16, gap: 12 }, - bigBtn: { - paddingVertical: 18, paddingHorizontal: 14, - borderWidth: 1, borderColor: '#000', alignItems: 'center', + crtScreen: { backgroundColor: '#000', borderRadius: 4, overflow: 'hidden' }, + crtImage: { opacity: 0.85 }, + scanline: { + position: 'absolute', left: 0, right: 0, height: 1, + backgroundColor: 'rgba(0,0,0,0.35)', }, - bigBtnTxt: { fontSize: 16, color: '#000' }, - windowRow: { - paddingVertical: 12, paddingHorizontal: 16, - borderBottomWidth: 1, borderBottomColor: '#eee', + glow: { + ...StyleSheet.absoluteFillObject, + backgroundColor: PHOSPHOR, }, - windowOwner: { fontSize: 15, color: '#000', fontWeight: '500' }, - windowTitle: { fontSize: 12, color: '#666', marginTop: 2 }, - previewWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 12 }, - previewBox: { borderWidth: 1, borderColor: '#000', overflow: 'hidden' }, selRect: { position: 'absolute', - borderWidth: 2, borderColor: '#000', - backgroundColor: 'rgba(0,0,0,0.1)', + borderWidth: 2, borderColor: PHOSPHOR, + backgroundColor: 'rgba(95,255,127,0.18)', + }, + selRectLabel: { + position: 'absolute', top: 2, left: 4, + color: PHOSPHOR, fontFamily: 'VT323', fontSize: 14, + }, + crtCornerTag: { + position: 'absolute', top: 4, right: 6, + color: PHOSPHOR, fontFamily: 'VT323', fontSize: 14, + }, + crtBottomTag: { + position: 'absolute', bottom: 4, left: 8, right: 8, + color: PHOSPHOR, fontFamily: 'VT323', fontSize: 12, }, - row: { - flexDirection: 'row', gap: 8, - paddingHorizontal: 12, paddingVertical: 8, - borderTopWidth: 1, borderTopColor: '#ccc', - alignItems: 'center', + crtBlank: { + width: 320, height: 200, backgroundColor: '#000', + alignItems: 'center', justifyContent: 'center', gap: 8, + }, + crtBlankTxt: { color: PHOSPHOR, fontFamily: 'VT323', fontSize: 22 }, + crtBlankSub: { color: PHOSPHOR, fontFamily: 'VT323', fontSize: 14, opacity: 0.7 }, + crtStandRow: { + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12, + marginTop: -2, + paddingHorizontal: 20, paddingVertical: 6, + backgroundColor: '#dcdcc8', + borderBottomLeftRadius: 16, borderBottomRightRadius: 16, + borderTopWidth: 1, borderTopColor: '#808070', + }, + crtStand: { width: 60, height: 4, backgroundColor: '#808070', borderRadius: 2 }, + crtKnob: { + width: 12, height: 12, borderRadius: 6, + backgroundColor: '#a0a090', + borderTopWidth: 1, borderLeftWidth: 1, + borderTopColor: '#fff', borderLeftColor: '#fff', + }, + crtLed: { + width: 6, height: 6, borderRadius: 3, + backgroundColor: PHOSPHOR, + }, + + controlRow: { + flexDirection: 'row', gap: 6, + paddingHorizontal: 6, paddingVertical: 6, + backgroundColor: theme.bg, alignItems: 'center', }, overlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, - backgroundColor: 'rgba(255,255,255,0.6)', + backgroundColor: 'rgba(192,192,192,0.6)', alignItems: 'center', justifyContent: 'center', }, }); diff --git a/src/types.ts b/src/types.ts index 5aca9ac..dd2f69b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,13 +1,31 @@ export type SortKey = 'date_desc' | 'date_asc' | 'name'; export type EntryKind = 'image' | 'folder'; export type Entry = { name: string; path: string; kind: EntryKind }; -export type Screen = 'browser' | 'preview' | 'settings' | 'capture' | 'refresh' | 'sourcepicker'; +export type Screen = + | 'browser' + | 'preview' + | 'settings' + | 'capture' + | 'refresh' + | 'sourcepicker' + | 'dropinbox' + | 'sendlasso'; + +export type DitherMode = 'none' | 'fs1' | 'fs4' | 'atkinson'; + +export const DITHER_LABELS: Record = { + none: 'Off', + fs1: '1-bit', + fs4: '4-gray', + atkinson: 'Atkinson', +}; export type Adjustments = { fade: number; brightness: number; contrast: number; gamma: number; + dither: DitherMode; }; export const DEFAULT_ADJUSTMENTS: Adjustments = { @@ -15,8 +33,11 @@ export const DEFAULT_ADJUSTMENTS: Adjustments = { brightness: 0, contrast: 0, gamma: 1.0, + dither: 'none', }; +export type Preset = Adjustments & { name: string }; + export type StreamConfig = { host: string; port: number; diff --git a/src/ui/Win95.tsx b/src/ui/Win95.tsx new file mode 100644 index 0000000..9cba128 --- /dev/null +++ b/src/ui/Win95.tsx @@ -0,0 +1,255 @@ +import React, { ReactNode, useState } from 'react'; +import { + GestureResponderEvent, + Pressable, + PressableProps, + StyleSheet, + Text, + TextProps, + View, + ViewProps, + ViewStyle, +} from 'react-native'; +import { insetEdges, outsetEdges, PIXEL_FONT, theme } from './theme'; + +// ---- Frames ---- + +export function Win95Frame({ children, style, ...rest }: ViewProps): React.JSX.Element { + return ( + + {children} + + ); +} + +export function Win95InsetPanel({ children, style, ...rest }: ViewProps): React.JSX.Element { + return ( + + {children} + + ); +} + +// ---- Button ---- + +export function Win95Button(props: PressableProps & { + children?: ReactNode; + active?: boolean; + primary?: boolean; + small?: boolean; +}): React.JSX.Element { + const { children, active, primary, small, style, ...rest } = props; + const [pressed, setPressed] = useState(false); + const looksDown = pressed || active; + + return ( + { setPressed(true); rest.onPressIn?.(e); }} + onPressOut={(e) => { setPressed(false); rest.onPressOut?.(e); }} + style={({ pressed: p }) => [ + styles.btnOuter, + looksDown || p ? insetEdges() : outsetEdges(), + small ? styles.btnSmall : null, + typeof style === 'function' ? (style as any)({ pressed: p }) : style, + ]} + > + + {typeof children === 'string' ? ( + {children} + ) : children} + + + ); +} + +// ---- Title bar (Win95 window header with X close button) ---- + +export function TitleBar({ + title, + onClose, +}: { + title: string; + onClose?: () => void; +}): React.JSX.Element { + return ( + + {title} + {onClose ? ( + + + x + + + ) : null} + + ); +} + +// ---- Menu bar ("File", "Edit"…) with simple drop-down ---- + +export type MenuItem = { + label: string; + onPress?: () => void; + separator?: boolean; + disabled?: boolean; +}; + +export type MenuSpec = { label: string; items: MenuItem[] }; + +export function MenuBar({ menus }: { menus: MenuSpec[] }): React.JSX.Element { + const [open, setOpen] = useState(null); + + return ( + + + {menus.map((m) => ( + setOpen((o) => (o === m.label ? null : m.label))} + style={[styles.menuLabel, open === m.label && styles.menuLabelOpen]} + > + + {m.label} + + + ))} + + {open ? ( + + {(menus.find((m) => m.label === open)?.items ?? []).map((it, i) => + it.separator ? ( + + ) : ( + { + setOpen(null); + it.onPress?.(); + }} + style={({ pressed }) => [ + styles.menuItem, + pressed && styles.menuItemPressed, + ]} + > + + {it.label} + + + ), + )} + + ) : null} + + ); +} + +// ---- Status bar (bottom strip used like a Win95 app status line) ---- + +export function StatusBar({ children }: { children: ReactNode }): React.JSX.Element { + return {typeof children === 'string' + ? {children} + : children}; +} + +export function Field({ children, style }: { children: ReactNode; style?: ViewStyle }): React.JSX.Element { + return {children}; +} + +export function PixelText({ children, style, ...rest }: TextProps): React.JSX.Element { + return {children}; +} + +const styles = StyleSheet.create({ + frameOuter: { + backgroundColor: theme.bg, + borderTopWidth: 1, borderLeftWidth: 1, borderRightWidth: 1, borderBottomWidth: 1, + borderTopColor: theme.highlight, borderLeftColor: theme.highlight, + borderRightColor: theme.dark, borderBottomColor: theme.dark, + }, + frameInner: { + borderTopWidth: 1, borderLeftWidth: 1, borderRightWidth: 1, borderBottomWidth: 1, + borderTopColor: theme.bg, borderLeftColor: theme.bg, + borderRightColor: theme.shadow, borderBottomColor: theme.shadow, + }, + inset: { + backgroundColor: theme.inset, + borderTopWidth: 1, borderLeftWidth: 1, borderRightWidth: 1, borderBottomWidth: 1, + borderTopColor: theme.shadow, borderLeftColor: theme.shadow, + borderRightColor: theme.highlight, borderBottomColor: theme.highlight, + }, + field: { paddingHorizontal: 8, paddingVertical: 6 }, + btnOuter: { + backgroundColor: theme.bg, + paddingHorizontal: 14, paddingVertical: 8, + }, + btnSmall: { paddingHorizontal: 10, paddingVertical: 4 }, + btnInner: { alignItems: 'center', justifyContent: 'center' }, + btnInnerSmall: {}, + btnPrimary: {}, + btnText: { + fontFamily: PIXEL_FONT, + fontSize: 18, + color: theme.text, + includeFontPadding: false as any, + }, + btnTextPrimary: { fontWeight: '700' as const }, + titleBar: { + flexDirection: 'row', alignItems: 'center', + backgroundColor: theme.titleBg, + paddingHorizontal: 4, paddingVertical: 2, + }, + titleText: { + flex: 1, color: theme.titleFg, + fontFamily: PIXEL_FONT, fontSize: 18, fontWeight: '700', + paddingHorizontal: 4, + }, + titleCloseOuter: { padding: 2 }, + titleCloseInner: { + width: 20, height: 18, + backgroundColor: theme.bg, + alignItems: 'center', justifyContent: 'center', + }, + titleCloseX: { fontFamily: PIXEL_FONT, fontSize: 16, color: theme.text, lineHeight: 16 }, + menuBarWrap: { position: 'relative', zIndex: 10 }, + menuBar: { + flexDirection: 'row', + backgroundColor: theme.bg, + paddingHorizontal: 2, paddingVertical: 2, + borderBottomWidth: 1, borderBottomColor: theme.shadow, + }, + menuLabel: { paddingHorizontal: 8, paddingVertical: 4 }, + menuLabelOpen: { backgroundColor: theme.selBg }, + menuLabelText: { fontFamily: PIXEL_FONT, fontSize: 16, color: theme.text }, + menuLabelTextOpen: { color: theme.selFg }, + menuDropdown: { + position: 'absolute', + top: 28, left: 4, + minWidth: 200, + paddingVertical: 2, + zIndex: 20, + elevation: 20, + }, + menuItem: { paddingHorizontal: 16, paddingVertical: 4 }, + menuItemPressed: { backgroundColor: theme.selBg }, + menuItemText: { fontFamily: PIXEL_FONT, fontSize: 16, color: theme.text }, + menuItemDisabled: { color: theme.shadow }, + menuSeparator: { + height: 2, marginVertical: 3, marginHorizontal: 4, + borderTopWidth: 1, borderTopColor: theme.shadow, + borderBottomWidth: 1, borderBottomColor: theme.highlight, + }, + statusBar: { + flexDirection: 'row', + backgroundColor: theme.bg, + paddingHorizontal: 4, paddingVertical: 4, + borderTopWidth: 1, borderTopColor: theme.shadow, + }, + statusText: { fontFamily: PIXEL_FONT, fontSize: 14, color: theme.text }, + pixel: { fontFamily: PIXEL_FONT, fontSize: 16, color: theme.text }, +}); diff --git a/src/ui/theme.ts b/src/ui/theme.ts new file mode 100644 index 0000000..8e10e56 --- /dev/null +++ b/src/ui/theme.ts @@ -0,0 +1,90 @@ +// Windows 95 design system. All visual chrome (buttons, frames, title +// bars, menu bars) lives here so we can re-skin the whole app from one +// place. The Manta is monochrome e-ink, so the "color" palette is +// pragmatic: shades of gray that render as flat tones (or dithered) +// on-device. The bevel illusion comes from contrasting border colors +// rather than gradients. + +import { TextStyle, ViewStyle } from 'react-native'; + +export const theme = { + // Classic Win95 silver. On the Manta this comes out as a light gray; + // the bevels still read clearly. + bg: '#c0c0c0', + panel: '#c0c0c0', + inset: '#ffffff', // input / list backgrounds + highlight: '#ffffff', // top + left edge of raised controls + shadow: '#808080', // mid bevel + dark: '#000000', // outer dark edge + text: '#000000', + textMuted: '#3f3f3f', + // "Active title bar" blue from Win95. Manta will render it dark; fine. + titleBg: '#000080', + titleFg: '#ffffff', + selBg: '#000080', + selFg: '#ffffff', + desktop: '#008080', // teal — region picker uses this +}; + +// Bundle this in android/app/src/main/assets/fonts/ as VT323-Regular.ttf +// to get true pixel typography. Falls back to monospace if missing. +// curl -L "https://fonts.gstatic.com/s/vt323/v17/pxiKyp0ihIEF2isfFJU.ttf" \ +// -o android/app/src/main/assets/fonts/VT323-Regular.ttf +export const PIXEL_FONT = 'VT323'; +export const SANS_FONT = 'monospace'; + +// Outset bevel: highlight on top/left, shadow on bottom/right, plus a +// 1px black outer ring. The doubled-up border-style approach RN doesn't +// support, so we layer two Views (see Win95Frame). +export function outsetEdges(): ViewStyle { + return { + borderTopWidth: 1, + borderLeftWidth: 1, + borderRightWidth: 1, + borderBottomWidth: 1, + borderTopColor: theme.highlight, + borderLeftColor: theme.highlight, + borderRightColor: theme.dark, + borderBottomColor: theme.dark, + }; +} + +export function insetEdges(): ViewStyle { + return { + borderTopWidth: 1, + borderLeftWidth: 1, + borderRightWidth: 1, + borderBottomWidth: 1, + borderTopColor: theme.dark, + borderLeftColor: theme.dark, + borderRightColor: theme.highlight, + borderBottomColor: theme.highlight, + }; +} + +export function inner3D(): ViewStyle { + // Secondary inner bevel — combined with outsetEdges this yields the + // double-line raised look used by Win95 buttons. + return { + borderTopWidth: 1, + borderLeftWidth: 1, + borderRightWidth: 1, + borderBottomWidth: 1, + borderTopColor: theme.bg, + borderLeftColor: theme.bg, + borderRightColor: theme.shadow, + borderBottomColor: theme.shadow, + }; +} + +export const pixelText: TextStyle = { + fontFamily: PIXEL_FONT, + fontSize: 16, + color: theme.text, +}; + +export const sansText: TextStyle = { + fontFamily: SANS_FONT, + fontSize: 12, + color: theme.text, +}; From 030c5e23be131592b2e05191a4da66950fe6a697 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 11:49:09 +0000 Subject: [PATCH 12/28] Fix BiRefNet on MPS + menu drop-downs hiding behind UI BiRefNet - The published weights ship in fp16; on MPS torchvision transforms produced fp32 input and the model crashed with "Input type (float) and bias type (c10::Half) should be the same". - Force the model to fp32 on load. Also cast input tensor to the model's dtype defensively, in case future revisions ship different precisions. MenuBar - Drop-downs were rendered as siblings of the menu bar inside a normal View; later sibling components (preview frames, status bar, etc.) drew on top of them and clicks needed multiple tries to land. - Render the open dropdown through a transparent so it's a top-level overlay above everything. The menu label is measured at open time and the dropdown anchors directly under it. Backdrop dismisses on any outside tap. Bump 0.7.0 -> 0.7.1 / versionCode 17 -> 18. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- PluginConfig.json | 4 +- macapp/server.py | 14 ++++-- src/ui/Win95.tsx | 107 ++++++++++++++++++++++++++++++++-------------- 3 files changed, 86 insertions(+), 39 deletions(-) diff --git a/PluginConfig.json b/PluginConfig.json index 1facf82..d23a352 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.7.0", - "versionCode": "17", + "versionName": "0.7.1", + "versionCode": "18", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/macapp/server.py b/macapp/server.py index 111b879..23ef48c 100644 --- a/macapp/server.py +++ b/macapp/server.py @@ -522,8 +522,13 @@ def _load_birefnet(): else "cuda" if torch.cuda.is_available() else "cpu" ) - model.to(device).eval() - log(f"BiRefNet ready on {device}") + # The published weights ship in fp16; on MPS this trips a + # "Input type (float) and bias type (c10::Half) should be the + # same" mismatch because torchvision's transforms produce fp32 + # tensors. Force the whole model to fp32 — it's a few hundred + # MB instead of a few hundred, and accuracy is unchanged. + model = model.to(torch.float32).to(device).eval() + log(f"BiRefNet ready on {device} (fp32)") _BIREFNET = {"model": model, "device": device, "torch": torch} return _BIREFNET @@ -543,9 +548,10 @@ def _birefnet_remove(img: Image.Image, bg: str) -> Image.Image: transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) rgb = img.convert("RGB") - inp = tfm(rgb).unsqueeze(0).to(device) + model_dtype = next(model.parameters()).dtype + inp = tfm(rgb).unsqueeze(0).to(device=device, dtype=model_dtype) with torch.no_grad(): - preds = model(inp)[-1].sigmoid().cpu() + preds = model(inp)[-1].sigmoid().to(dtype=torch.float32).cpu() mask = preds[0].squeeze() from PIL import Image as PI mask_pil = transforms.ToPILImage()(mask).resize(rgb.size, PI.BILINEAR) diff --git a/src/ui/Win95.tsx b/src/ui/Win95.tsx index 9cba128..2ad5a97 100644 --- a/src/ui/Win95.tsx +++ b/src/ui/Win95.tsx @@ -1,6 +1,7 @@ -import React, { ReactNode, useState } from 'react'; +import React, { ReactNode, useRef, useState } from 'react'; import { GestureResponderEvent, + Modal, Pressable, PressableProps, StyleSheet, @@ -103,6 +104,28 @@ export type MenuSpec = { label: string; items: MenuItem[] }; export function MenuBar({ menus }: { menus: MenuSpec[] }): React.JSX.Element { const [open, setOpen] = useState(null); + // Pixel-position of the open menu label so the dropdown anchors to it. + const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null); + const labelRefs = useRef>({}); + + const openMenu = (label: string) => { + const ref = labelRefs.current[label]; + if (ref) { + ref.measureInWindow((x, y, _w, h) => { + setAnchor({ x, y: y + h }); + setOpen(label); + }); + } else { + setOpen(label); + } + }; + + const close = () => { + setOpen(null); + setAnchor(null); + }; + + const items = open ? menus.find((m) => m.label === open)?.items ?? [] : []; return ( @@ -110,7 +133,8 @@ export function MenuBar({ menus }: { menus: MenuSpec[] }): React.JSX.Element { {menus.map((m) => ( setOpen((o) => (o === m.label ? null : m.label))} + ref={(r) => { labelRefs.current[m.label] = r; }} + onPress={() => (open === m.label ? close() : openMenu(m.label))} style={[styles.menuLabel, open === m.label && styles.menuLabelOpen]} > @@ -119,32 +143,51 @@ export function MenuBar({ menus }: { menus: MenuSpec[] }): React.JSX.Element { ))} - {open ? ( - - {(menus.find((m) => m.label === open)?.items ?? []).map((it, i) => - it.separator ? ( - - ) : ( - { - setOpen(null); - it.onPress?.(); - }} - style={({ pressed }) => [ - styles.menuItem, - pressed && styles.menuItemPressed, - ]} - > - - {it.label} - - - ), - )} - - ) : null} + + {/* Backdrop captures taps outside the menu so any tap closes it. */} + + {/* Inner Pressable swallows the tap so clicks inside the + dropdown don't bubble to the backdrop and dismiss it. */} + {}} + style={[ + styles.menuFloatingWrap, + anchor ? { top: anchor.y, left: anchor.x } : null, + ]} + > + + {items.map((it, i) => + it.separator ? ( + + ) : ( + { + close(); + it.onPress?.(); + }} + style={({ pressed }) => [ + styles.menuItem, + pressed && styles.menuItemPressed, + ]} + > + + {it.label} + + + ), + )} + + + + ); } @@ -227,13 +270,11 @@ const styles = StyleSheet.create({ menuLabelOpen: { backgroundColor: theme.selBg }, menuLabelText: { fontFamily: PIXEL_FONT, fontSize: 16, color: theme.text }, menuLabelTextOpen: { color: theme.selFg }, + menuBackdrop: { flex: 1 }, + menuFloatingWrap: { position: 'absolute' }, menuDropdown: { - position: 'absolute', - top: 28, left: 4, - minWidth: 200, + minWidth: 220, paddingVertical: 2, - zIndex: 20, - elevation: 20, }, menuItem: { paddingHorizontal: 16, paddingVertical: 4 }, menuItemPressed: { backgroundColor: theme.selBg }, From f6f5d3f2b4b3374697322f145c1046f7b70e3de3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 12:05:12 +0000 Subject: [PATCH 13/28] Harden Refresh / Lasso / DropInbox flows; catch async button-reg failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watchdogs on the headless-ish screens - RefreshScreen: 15s hard ceiling. If any SDK call hangs (the embedded insertAndTrack / replaceInPlace can stall when the note context is off), we set a timeout message and close instead of leaving the spinner running forever. Also publishes per-stage status so the user knows where it got stuck. - SendLasso: 20s watchdog + per-stage status. Lasso export errors are caught explicitly (was bubbling up before). Auto-closes on success after a 1.5s confirm — no need to tap OK. DropInbox robustness - Bumped /drop/list timeout 3s -> 5s. - Thumbnail prefetch loop uses a generation counter so a Refresh tap (or unmount) cleanly invalidates the old run. Each tile updates incrementally as its thumbnail arrives — no big batch at the end that can clobber newer state. - A single failing fetch can't stall the row (per-fetch 5s timeout). Async button registration - registerButton returns a Promise; the sync try/catch around the type=2 lasso button couldn't see async rejection. If the host rejected (older firmware), we ended up with a half-registered button that could confuse the lasso menu. All registerButton calls now have .catch() handlers that log + skip. Bump 0.7.1 -> 0.7.2 / versionCode 18 -> 19. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- PluginConfig.json | 4 +- index.js | 31 ++++++++------- src/screens/DropInbox.tsx | 84 +++++++++++++++++++++++++-------------- src/screens/Refresh.tsx | 18 +++++++++ src/screens/SendLasso.tsx | 41 ++++++++++++++++--- 5 files changed, 127 insertions(+), 51 deletions(-) diff --git a/PluginConfig.json b/PluginConfig.json index d23a352..148c91c 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.7.1", - "versionCode": "18", + "versionName": "0.7.2", + "versionCode": "19", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/index.js b/index.js index 6aebd86..1cbf43d 100644 --- a/index.js +++ b/index.js @@ -22,34 +22,37 @@ PluginManager.registerButton(1, ['NOTE'], { name: 'Embed Image', icon: ICON, showType: 1, -}); +}).catch((e) => console.log('[embedimage] main button register failed:', e)); PluginManager.registerButton(1, ['NOTE'], { id: BUTTON_REFRESH, name: 'Refresh Embed', icon: ICON, showType: 1, -}); +}).catch((e) => console.log('[embedimage] refresh button register failed:', e)); PluginManager.registerButton(1, ['NOTE'], { id: BUTTON_DROP, name: 'Drop Inbox', icon: ICON, showType: 1, -}); +}).catch((e) => console.log('[embedimage] drop button register failed:', e)); // Lasso-toolbar button (type=2). Appears when the user lasso-selects ink -// on the page; tap to ship the selection to the Mac. -try { - PluginManager.registerButton(2, ['NOTE'], { - id: BUTTON_LASSO_SEND, - name: 'Send to Mac', - icon: ICON, - showType: 1, - }); -} catch (e) { - // Older host versions may not support type=2; fall back silently. -} +// on the page; tap to ship the selection to the Mac. registerButton +// returns a Promise, so a sync try/catch isn't enough — catch the +// rejection so an older host that rejects the call doesn't end up with +// a half-registered button confusing the lasso menu. +Promise.resolve() + .then(() => + PluginManager.registerButton(2, ['NOTE'], { + id: BUTTON_LASSO_SEND, + name: 'Send to Mac', + icon: ICON, + showType: 1, + }), + ) + .catch((e) => console.log('[embedimage] lasso button register skipped:', e)); PluginManager.registerButtonListener({ onButtonPress: (_msg) => { diff --git a/src/screens/DropInbox.tsx b/src/screens/DropInbox.tsx index 0e1ec4c..a916aff 100644 --- a/src/screens/DropInbox.tsx +++ b/src/screens/DropInbox.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { ActivityIndicator, FlatList, @@ -31,48 +31,72 @@ export function DropInbox({ onClose }: { onClose: () => void }): React.JSX.Eleme const [busy, setBusy] = useState(false); const [thumbs, setThumbs] = useState>({}); + // Used by the prefetch loop to bail when the user navigates away or + // hits Refresh again — we don't want an old run's setState to clobber + // a newer one with stale results. + const refreshGen = useRef(0); + const refresh = useCallback(async (u: string) => { if (!u) return; - setStatus('refreshing…'); + const myGen = ++refreshGen.current; + setStatus('Refreshing…'); + let list: DropItem[] = []; try { - const res: any = await lanJson('GET', `${u}/drop/list`, undefined, 3000); - const list: DropItem[] = Array.isArray(res?.items) ? res.items : []; + const res: any = await lanJson('GET', `${u}/drop/list`, undefined, 5000); + if (refreshGen.current !== myGen) return; + list = Array.isArray(res?.items) ? res.items : []; setItems(list); setFolder(String(res?.folder ?? '')); setStatus(`${list.length} item${list.length === 1 ? '' : 's'}`); - // Pre-fetch small thumbnails for the visible list. - const newThumbs: Record = {}; - for (const it of list.slice(0, 24)) { - try { - const path = await downloadAndBake( - `${u}/drop/file?name=${encodeURIComponent(it.name)}`, - DEFAULT_ADJUSTMENTS, - 200, // small thumb - 8000, - ); - newThumbs[it.name] = 'file://' + path; - } catch { - // skip; show name only - } - } - setThumbs(newThumbs); } catch (e: any) { - setStatus(`failed: ${e?.message ?? e}`); + if (refreshGen.current !== myGen) return; + setStatus(`List failed: ${e?.message ?? e}`); + return; + } + // Pre-fetch small thumbnails for the visible list. Each fetch has + // its own short timeout; a single dead file can't stall the row. + const newThumbs: Record = {}; + for (const it of list.slice(0, 24)) { + if (refreshGen.current !== myGen) return; + try { + const path = await downloadAndBake( + `${u}/drop/file?name=${encodeURIComponent(it.name)}`, + DEFAULT_ADJUSTMENTS, + 200, + 5000, + ); + if (refreshGen.current !== myGen) return; + newThumbs[it.name] = 'file://' + path; + setThumbs((prev) => ({ ...prev, [it.name]: 'file://' + path })); + } catch { + // skip; tile shows the placeholder. + } } }, []); useEffect(() => { + let alive = true; (async () => { - const cfg = await loadStreamConfig(); - const u = baseUrl(cfg); - setUrl(u); - if (u) refresh(u); - else setStatus('no server configured'); + try { + const cfg = await loadStreamConfig(); + if (!alive) return; + const u = baseUrl(cfg); + setUrl(u); + if (u) refresh(u); + else setStatus('no server configured'); + } catch (e: any) { + if (!alive) return; + setStatus(`init failed: ${e?.message ?? e}`); + } })(); + return () => { + alive = false; + refreshGen.current++; // invalidate any in-flight refresh + }; }, [refresh]); const onInsert = useCallback(async (it: DropItem) => { - if (busy) return; + if (busy || !url) return; setBusy(true); setStatus(`embedding ${it.name}…`); try { @@ -83,11 +107,13 @@ export function DropInbox({ onClose }: { onClose: () => void }): React.JSX.Eleme 15000, ); await insertAndTrack(path); - // Consume on the Mac (moves it to .consumed/) and refresh. + // Consume on the Mac (moves it to .consumed/). Best-effort; if it + // fails the file just shows up again on next Refresh which is + // harmless. await lanHttp('POST', `${url}/drop/consume`, { name: it.name }, 3000).catch(() => {}); await PluginManager.closePluginView().catch(() => {}); } catch (e: any) { - setStatus(`insert failed: ${e?.message ?? e}`); + setStatus(`Insert failed: ${e?.message ?? e}`); setBusy(false); } }, [busy, url]); diff --git a/src/screens/Refresh.tsx b/src/screens/Refresh.tsx index 5e1c1e6..f70dfd0 100644 --- a/src/screens/Refresh.tsx +++ b/src/screens/Refresh.tsx @@ -27,8 +27,21 @@ export function RefreshScreen(): React.JSX.Element { useEffect(() => { let cancelled = false; + + // Hard ceiling on the whole flow. Without this, a single hung SDK + // call (insertAndTrack / replaceInPlace can stall on a bad note + // context) leaves the spinner running forever. + const WATCHDOG_MS = 15000; + const watchdog = setTimeout(() => { + if (cancelled) return; + cancelled = true; + setStatus('Timed out. Closing…'); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 1200); + }, WATCHDOG_MS); + (async () => { try { + setStatus('Loading config…'); const cfg = await loadStreamConfig(); const url = baseUrl(cfg); if (!url) { @@ -36,9 +49,12 @@ export function RefreshScreen(): React.JSX.Element { setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2500); return; } + if (cancelled) return; + setStatus('Fetching frame from Mac…'); const track = await loadEmbedTrack(); const fullPath = await downloadAndBake(`${url}/frame`, DEFAULT_ADJUSTMENTS, 0, 8000); if (cancelled) return; + setStatus(track ? 'Replacing embed…' : 'Inserting…'); if (track) { await replaceInPlace(track, fullPath); } else { @@ -47,12 +63,14 @@ export function RefreshScreen(): React.JSX.Element { if (cancelled) return; await PluginManager.closePluginView().catch(() => {}); } catch (e: any) { + if (cancelled) return; setStatus(`FAILED: ${e?.message ?? e}`); setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2500); } })(); return () => { cancelled = true; + clearTimeout(watchdog); }; }, []); diff --git a/src/screens/SendLasso.tsx b/src/screens/SendLasso.tsx index cb83925..4da5dd2 100644 --- a/src/screens/SendLasso.tsx +++ b/src/screens/SendLasso.tsx @@ -25,26 +25,49 @@ export function SendLasso({ onClose }: { onClose: () => void }): React.JSX.Eleme useEffect(() => { let cancelled = false; + + const WATCHDOG_MS = 20000; + const watchdog = setTimeout(() => { + if (cancelled) return; + cancelled = true; + setStatus('Timed out. Closing…'); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 1500); + }, WATCHDOG_MS); + (async () => { try { + setStatus('Loading config…'); const cfg = await loadStreamConfig(); const url = baseUrl(cfg); if (!url) { setStatus('No Mac server set. Settings → Mac Capture Server.'); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2500); + return; + } + if (cancelled) return; + setStatus('Exporting lasso…'); + // Ask the SDK to write the lasso preview. We pass a writable + // directory it can create files under; the SDK returns the + // actual file path it chose. + const tmpPath = `/data/local/tmp/lasso_${Date.now()}.png`; + let res: any; + try { + res = await PluginCommAPI.generateLassoPreview(tmpPath); + } catch (e: any) { + setStatus(`Lasso export failed: ${e?.message ?? e}`); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2500); return; } - // Ask the SDK to write the lasso preview to a file we control. - const ts = Date.now(); - // The plugin's sandbox cache is the safe place to write. - const tmpPath = `/data/user/0/com.ratta.supernote.pluginhost/files/plugins/embedimagev0001a/lasso_${ts}.png`; - const res: any = await PluginCommAPI.generateLassoPreview(tmpPath); if (!res || res.success === false) { - setStatus(`No lasso content. Lasso something first, then tap again.`); + const msg = res?.error?.message ?? 'no lasso content'; + setStatus(`Lasso empty: ${msg}\n(Lasso something first, then tap again.)`); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 3000); return; } const resultPath: string = typeof res?.result === 'string' ? res.result : (res?.result?.path ?? tmpPath); + if (cancelled) return; setPreviewUri('file://' + resultPath); setStatus('Uploading to Mac…'); const name = `manta_${new Date().toISOString().replace(/[:.]/g, '-')}.png`; @@ -52,12 +75,18 @@ export function SendLasso({ onClose }: { onClose: () => void }): React.JSX.Eleme if (cancelled) return; setStatus(`Sent to Mac as ${name}`); setDone(true); + // Auto-close after a brief confirm so the user doesn't have to + // tap OK every time. + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 1500); } catch (e: any) { + if (cancelled) return; setStatus(`Failed: ${e?.message ?? e}`); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2500); } })(); return () => { cancelled = true; + clearTimeout(watchdog); }; }, []); From 48135e3f700b58056387a5ef68b209f7847db51f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 12:29:01 +0000 Subject: [PATCH 14/28] Fix lasso "..." menu crash by giving buttons nameMap + editDataTypes Logcat showed the smoking gun: java.lang.NullPointerException at HashSet.(HashSet.java:118) at PluginButtonAdapter.onBindViewHolder(PluginButtonAdapter.java:195) and at registration time: PluginJSExceptionHandler error: NullPointerException: Attempt to invoke 'java.util.Iterator java.util.List.iterator()' on a null object reference right after our type=2 button got logged with nameMap=null, descMap=null, and no editDataTypes. The host's area-selection adapter does `new HashSet<>(nameMap.keySet())` without a null guard, so the moment the user tapped the lasso "..." menu the whole note app crashed. The earlier registration NPE was the same root cause from a different code path (also missing editDataTypes for the lasso button kind). - Every registerButton call now supplies nameMap and descMap (English for now; can localize later) via a shared helper. - Lasso button additionally supplies editDataTypes covering all six lasso content kinds so the button shows up no matter what was selected. Bump 0.7.2 -> 0.7.3 / versionCode 19 -> 20. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- PluginConfig.json | 4 +-- index.js | 70 +++++++++++++++++++++++++---------------------- 2 files changed, 39 insertions(+), 35 deletions(-) diff --git a/PluginConfig.json b/PluginConfig.json index 148c91c..95136dc 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.7.2", - "versionCode": "19", + "versionName": "0.7.3", + "versionCode": "20", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/index.js b/index.js index 1cbf43d..facb901 100644 --- a/index.js +++ b/index.js @@ -16,43 +16,47 @@ export const BUTTON_LASSO_SEND = 4; // lasso-toolbar button (type=2) const ICON = Image.resolveAssetSource(require('./assets/icon.png')).uri; -// Sidebar buttons (type=1). -PluginManager.registerButton(1, ['NOTE'], { - id: BUTTON_MAIN, - name: 'Embed Image', - icon: ICON, - showType: 1, -}).catch((e) => console.log('[embedimage] main button register failed:', e)); +// The Supernote host crashes (java.lang.NullPointerException inside +// PluginButtonAdapter / the lifecycle that registers the button) if +// nameMap or descMap is missing — its adapter does +// `new HashSet<>(button.nameMap.keySet())` without a null check. Provide +// at least the English fallback for every button. +function mkName(label) { + return { en: label, zh_CN: label, zh_TW: label, ja: label }; +} -PluginManager.registerButton(1, ['NOTE'], { - id: BUTTON_REFRESH, - name: 'Refresh Embed', +const baseBtn = (id, label) => ({ + id, + name: label, + nameMap: mkName(label), + desc: '', + descMap: mkName(''), icon: ICON, showType: 1, -}).catch((e) => console.log('[embedimage] refresh button register failed:', e)); +}); -PluginManager.registerButton(1, ['NOTE'], { - id: BUTTON_DROP, - name: 'Drop Inbox', - icon: ICON, - showType: 1, -}).catch((e) => console.log('[embedimage] drop button register failed:', e)); - -// Lasso-toolbar button (type=2). Appears when the user lasso-selects ink -// on the page; tap to ship the selection to the Mac. registerButton -// returns a Promise, so a sync try/catch isn't enough — catch the -// rejection so an older host that rejects the call doesn't end up with -// a half-registered button confusing the lasso menu. -Promise.resolve() - .then(() => - PluginManager.registerButton(2, ['NOTE'], { - id: BUTTON_LASSO_SEND, - name: 'Send to Mac', - icon: ICON, - showType: 1, - }), - ) - .catch((e) => console.log('[embedimage] lasso button register skipped:', e)); +// Sidebar buttons (type=1). The host accepts these even with a null +// nameMap on current firmware, but we include it defensively in case +// a future build tightens the adapter the way the lasso one does. +PluginManager.registerButton(1, ['NOTE'], baseBtn(BUTTON_MAIN, 'Embed Image')) + .catch((e) => console.log('[embedimage] main button register failed:', e)); + +PluginManager.registerButton(1, ['NOTE'], baseBtn(BUTTON_REFRESH, 'Refresh Embed')) + .catch((e) => console.log('[embedimage] refresh button register failed:', e)); + +PluginManager.registerButton(1, ['NOTE'], baseBtn(BUTTON_DROP, 'Drop Inbox')) + .catch((e) => console.log('[embedimage] drop button register failed:', e)); + +// Lasso-toolbar button (type=2). The host's area-selection adapter +// expects `editDataTypes` (which lasso content kinds the button applies +// to) and `nameMap`. With either missing it throws a HashSet NPE when +// the user opens the "..." menu and the menu crashes the note app. +// editDataTypes values per the SDK: +// 0 handwritten strokes 1 title 2 image 3 text 4 link 5 shapes +PluginManager.registerButton(2, ['NOTE'], { + ...baseBtn(BUTTON_LASSO_SEND, 'Send to Mac'), + editDataTypes: [0, 1, 2, 3, 4, 5], +}).catch((e) => console.log('[embedimage] lasso button register skipped:', e)); PluginManager.registerButtonListener({ onButtonPress: (_msg) => { From 7efab0a02918682bf6aa5c87fa46e82dae71a9a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 12:43:30 +0000 Subject: [PATCH 15/28] Fix lasso send to Mac, and refresh-embed losing the image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Send to Mac - generateLassoPreview was being asked to write to /data/local/tmp/, which is adb-shell-only — the plugin process can't write there so the SDK silently returned no content. Move to the pluginhost's own cache dir (/data/user/0/com.ratta.supernote.pluginhost/cache/), which we know is writable because downloadAndProcess already writes there. Refresh embed - Old order was delete-then-insert. When the insert step failed (any validation hiccup, transient SDK error, etc) the user was left with no embed at all: the delete had already committed. - Try modifyElements first — atomic, matches by uuid+numInPage. If the SDK accepts it, the picture path is swapped in place and the rect is preserved. - Fallback path is insert-then-delete (note the reversed order!). A failed insert now leaves the original embed untouched; a failed delete leaves two pictures, which is still better than zero. Bump 0.7.3 -> 0.7.4 / versionCode 20 -> 21. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- PluginConfig.json | 4 +-- src/embedTracker.ts | 57 ++++++++++++++++++++++++++++++--------- src/screens/SendLasso.tsx | 8 +++--- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/PluginConfig.json b/PluginConfig.json index 95136dc..e9eb9ca 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.7.3", - "versionCode": "20", + "versionName": "0.7.4", + "versionCode": "21", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/src/embedTracker.ts b/src/embedTracker.ts index aaa0d37..aab58db 100644 --- a/src/embedTracker.ts +++ b/src/embedTracker.ts @@ -88,14 +88,7 @@ async function refreshTrackRect(track: EmbedTrack): Promise { export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Promise { const fresh = await refreshTrackRect(track); - // Delete the previous embed by its index within the page. - try { - await PluginFileAPI.deleteElements(fresh.notePath, fresh.page, [fresh.numInPage]); - } catch (e: any) { - throw new Error(`delete failed: ${e?.message ?? e}`); - } - - const newElement = { + const elementBase = { type: 200, // Element.TYPE_PICTURE pageNum: fresh.page, layerNum: fresh.layerNum, @@ -105,13 +98,47 @@ export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Pro }, }; + // Preferred path: ask the SDK to modify the existing element in place. + // This is atomic from the user's POV so a failure can never leave the + // page with no embed (the previous bug was delete-then-insert: when + // insert failed the user lost their image with nothing to show). + let modifiedOk = false; try { - const ins: any = await PluginFileAPI.insertElements(fresh.notePath, fresh.page, [newElement as any]); - if (ins && ins.success === false) { - throw new Error(ins?.error?.message ?? 'insertElements failed'); + const modElement = { + ...elementBase, + uuid: fresh.uuid, + numInPage: fresh.numInPage, + }; + const mod: any = await PluginFileAPI.modifyElements( + fresh.notePath, fresh.page, [modElement as any], + ); + if (mod && mod.success !== false) { + const idxs: any[] = mod?.result ?? []; + modifiedOk = Array.isArray(idxs) ? idxs.length > 0 : true; + } + } catch { + modifiedOk = false; + } + + // Fallback path: insert-then-delete. This way a failed insert leaves + // the original embed untouched (the opposite of delete-then-insert). + if (!modifiedOk) { + try { + const ins: any = await PluginFileAPI.insertElements( + fresh.notePath, fresh.page, [elementBase as any], + ); + if (ins && ins.success === false) { + throw new Error(ins?.error?.message ?? 'insertElements failed'); + } + } catch (e: any) { + throw new Error(`insert failed (original embed kept): ${e?.message ?? e}`); + } + // Only delete the old one once the new one is safely in place. + try { + await PluginFileAPI.deleteElements(fresh.notePath, fresh.page, [fresh.numInPage]); + } catch { + // Non-fatal: user now has two pictures; better than zero. } - } catch (e: any) { - throw new Error(`insert failed: ${e?.message ?? e}`); } try { @@ -126,6 +153,10 @@ export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Pro } catch { newTrack = null; } + // If modifyElements worked the uuid stays the same and getLastElement + // may return a different element (e.g. an ink stroke added after); + // fall back to the existing track so the next Replace still works. + if (!newTrack && modifiedOk) newTrack = fresh; if (newTrack) await saveEmbedTrack(newTrack).catch(() => {}); return newTrack; } diff --git a/src/screens/SendLasso.tsx b/src/screens/SendLasso.tsx index 4da5dd2..8ff8eb7 100644 --- a/src/screens/SendLasso.tsx +++ b/src/screens/SendLasso.tsx @@ -46,10 +46,10 @@ export function SendLasso({ onClose }: { onClose: () => void }): React.JSX.Eleme } if (cancelled) return; setStatus('Exporting lasso…'); - // Ask the SDK to write the lasso preview. We pass a writable - // directory it can create files under; the SDK returns the - // actual file path it chose. - const tmpPath = `/data/local/tmp/lasso_${Date.now()}.png`; + // The plugin process can write into the pluginhost's own cache + // dir; /data/local/tmp is adb-shell-only so the SDK silently + // fails to write there. + const tmpPath = `/data/user/0/com.ratta.supernote.pluginhost/cache/lasso_${Date.now()}.png`; let res: any; try { res = await PluginCommAPI.generateLassoPreview(tmpPath); From a001eddc62a05fa68520692cef06b2545e89c5a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 12:59:39 +0000 Subject: [PATCH 16/28] Refresh embed: drop modifyElements path, insert-then-delete with logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logs from 0.7.4 showed modifyElements running cleanly + saveTrailsAsTemp succeeding, but the displayed picture never repainted. The renderer caches the picture bitmap by element identity; a path-only swap via modifyElements isn't enough to invalidate that cache. - Drop modifyElements entirely. Always go through insertElements followed by deleteElements (in that order — the previous bug came from delete-first; an insert failure left the user with nothing). - If insertElements rejects, fall back to PluginNoteAPI.insertImage() which is the same call the first-time embed uses. We lose the rect in that case but the user sees a fresh frame and can re-position. - Heavy console.log around each SDK call. Next time something fails the response shape will be visible in adb logcat instead of having to guess. Bump 0.7.4 -> 0.7.5 / versionCode 21 -> 22. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- PluginConfig.json | 4 +- src/embedTracker.ts | 93 +++++++++++++++++++++++++-------------------- 2 files changed, 53 insertions(+), 44 deletions(-) diff --git a/PluginConfig.json b/PluginConfig.json index e9eb9ca..66f2333 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.7.4", - "versionCode": "21", + "versionName": "0.7.5", + "versionCode": "22", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/src/embedTracker.ts b/src/embedTracker.ts index aab58db..745144e 100644 --- a/src/embedTracker.ts +++ b/src/embedTracker.ts @@ -87,8 +87,13 @@ async function refreshTrackRect(track: EmbedTrack): Promise { export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Promise { const fresh = await refreshTrackRect(track); + console.log('[embedimage] replaceInPlace start', { + notePath: fresh.notePath, page: fresh.page, + numInPage: fresh.numInPage, layerNum: fresh.layerNum, + uuid: fresh.uuid, rect: fresh.rect, newPngPath, + }); - const elementBase = { + const newElement: any = { type: 200, // Element.TYPE_PICTURE pageNum: fresh.page, layerNum: fresh.layerNum, @@ -98,65 +103,69 @@ export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Pro }, }; - // Preferred path: ask the SDK to modify the existing element in place. - // This is atomic from the user's POV so a failure can never leave the - // page with no embed (the previous bug was delete-then-insert: when - // insert failed the user lost their image with nothing to show). - let modifiedOk = false; + // Insert the new picture FIRST. We learned the hard way that the + // opposite order (delete then insert) can leave the user with no + // embed at all if the insert step silently fails. We also avoid the + // modifyElements path entirely — the SDK accepted it on 0.7.4 logs + // but the displayed picture never repainted (the renderer caches the + // bitmap by element identity and a path-only change doesn't + // invalidate that cache). + let insertOk = false; + let insertErr: any = null; try { - const modElement = { - ...elementBase, - uuid: fresh.uuid, - numInPage: fresh.numInPage, - }; - const mod: any = await PluginFileAPI.modifyElements( - fresh.notePath, fresh.page, [modElement as any], + const ins: any = await PluginFileAPI.insertElements( + fresh.notePath, fresh.page, [newElement], ); - if (mod && mod.success !== false) { - const idxs: any[] = mod?.result ?? []; - modifiedOk = Array.isArray(idxs) ? idxs.length > 0 : true; + console.log('[embedimage] insertElements ->', JSON.stringify(ins)); + if (ins && ins.success !== false) { + insertOk = true; + } else { + insertErr = ins?.error?.message ?? 'insertElements rejected'; } - } catch { - modifiedOk = false; + } catch (e: any) { + insertErr = e?.message ?? String(e); + console.log('[embedimage] insertElements threw:', insertErr); } - // Fallback path: insert-then-delete. This way a failed insert leaves - // the original embed untouched (the opposite of delete-then-insert). - if (!modifiedOk) { - try { - const ins: any = await PluginFileAPI.insertElements( - fresh.notePath, fresh.page, [elementBase as any], - ); - if (ins && ins.success === false) { - throw new Error(ins?.error?.message ?? 'insertElements failed'); - } - } catch (e: any) { - throw new Error(`insert failed (original embed kept): ${e?.message ?? e}`); - } - // Only delete the old one once the new one is safely in place. - try { - await PluginFileAPI.deleteElements(fresh.notePath, fresh.page, [fresh.numInPage]); - } catch { - // Non-fatal: user now has two pictures; better than zero. + if (!insertOk) { + // Fallback: insertImage uses the host's default position so we + // lose the rect, but the user at least sees a fresh frame. They + // can re-position with the lasso afterwards. + console.log('[embedimage] insertElements failed (', insertErr, ') — falling back to insertImage'); + const img: any = await PluginNoteAPI.insertImage(newPngPath); + console.log('[embedimage] insertImage ->', JSON.stringify(img)); + if (!img || img.success === false) { + throw new Error(`insert failed (original embed kept): ${img?.error?.message ?? insertErr ?? 'unknown'}`); } } + // Only delete the old embed now that something has been re-inserted. + try { + const del: any = await PluginFileAPI.deleteElements( + fresh.notePath, fresh.page, [fresh.numInPage], + ); + console.log('[embedimage] deleteElements ->', JSON.stringify(del)); + } catch (e: any) { + console.log('[embedimage] deleteElements threw:', e?.message ?? e); + // Non-fatal: user now sees two pictures, better than zero. + } + try { await PluginNoteAPI.saveCurrentNote(); - } catch {} + } catch (e: any) { + console.log('[embedimage] saveCurrentNote threw:', e?.message ?? e); + } let newTrack: EmbedTrack | null = null; try { const lastRes: any = await PluginFileAPI.getLastElement(); const el = lastRes?.result ?? lastRes; + console.log('[embedimage] getLastElement after replace ->', JSON.stringify(el)?.slice(0, 400)); newTrack = fromElement(fresh.notePath, fresh.page, el); - } catch { + } catch (e: any) { + console.log('[embedimage] getLastElement threw:', e?.message ?? e); newTrack = null; } - // If modifyElements worked the uuid stays the same and getLastElement - // may return a different element (e.g. an ink stroke added after); - // fall back to the existing track so the next Replace still works. - if (!newTrack && modifiedOk) newTrack = fresh; if (newTrack) await saveEmbedTrack(newTrack).catch(() => {}); return newTrack; } From d0813431250f92b278834591ba49c59922f2cebc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 13:23:19 +0000 Subject: [PATCH 17/28] Refresh: clone existing element; SendLasso: log every step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refresh embed - Logcat showed insertElements rejecting with code 106 "Invalid API parameters". A bare {type, pageNum, layerNum, picture} payload isn't enough — the host validates a bunch of fields a real picture element carries (maxX, maxY, thickness, status, recognizeResult, ...). - Fetch the raw existing element via getElements, deep-clone it, then swap only picture.picturePath. Drop uuid and numInPage so the host assigns fresh ones. That gives us a payload the host accepts because it's identical in shape to one it produced itself. - Drop the insertImage fallback too: observed runs put the new picture at pageNum=-1, which means the user never sees it. Better to fail loudly and keep the original embed than silently put it off-page. SendLasso - Add console.log around every awaited step (config load, generateLassoPreview call & result, upload). Last log only showed the verifyParams line, so the user couldn't tell whether the SDK call failed or the upload did. Now adb logcat will show exactly which step stalls. Bump 0.7.5 -> 0.7.6 / versionCode 22 -> 23. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- PluginConfig.json | 4 +- src/embedTracker.ts | 81 +++++++++++++++++++++++---------------- src/screens/SendLasso.tsx | 23 ++++++----- 3 files changed, 63 insertions(+), 45 deletions(-) diff --git a/PluginConfig.json b/PluginConfig.json index 66f2333..9dd5c39 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.7.5", - "versionCode": "22", + "versionName": "0.7.6", + "versionCode": "23", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/src/embedTracker.ts b/src/embedTracker.ts index 745144e..f0a5cab 100644 --- a/src/embedTracker.ts +++ b/src/embedTracker.ts @@ -70,46 +70,64 @@ export async function insertAndTrack(pngPath: string): Promise { - if (!track.uuid) return track; +// Re-fetch the tracked element so we pick up any rect changes from the lasso, +// AND return the raw SDK element so callers can clone it verbatim. Picture +// elements have a bunch of fields (maxX, maxY, thickness, status, +// recognizeResult, ...) that the host validates on insertElements — we +// learned the hard way that omitting them gets HTTP 106 "Invalid API +// parameters". +async function refreshTrackAndRaw( + track: EmbedTrack, +): Promise<{ track: EmbedTrack; raw: any | null }> { + if (!track.uuid) return { track, raw: null }; try { const res: any = await PluginFileAPI.getElements(track.page, track.notePath); const list: any[] = res?.result ?? (Array.isArray(res) ? res : []); const found = list.find((e) => e?.uuid === track.uuid); - if (!found) return track; + if (!found) return { track, raw: null }; const fresh = fromElement(track.notePath, track.page, found); - return fresh ?? track; + return { track: fresh ?? track, raw: found }; } catch { - return track; + return { track, raw: null }; } } export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Promise { - const fresh = await refreshTrackRect(track); + const { track: fresh, raw } = await refreshTrackAndRaw(track); console.log('[embedimage] replaceInPlace start', { notePath: fresh.notePath, page: fresh.page, numInPage: fresh.numInPage, layerNum: fresh.layerNum, uuid: fresh.uuid, rect: fresh.rect, newPngPath, + rawKeys: raw ? Object.keys(raw) : null, }); - const newElement: any = { - type: 200, // Element.TYPE_PICTURE - pageNum: fresh.page, - layerNum: fresh.layerNum, - picture: { - picturePath: newPngPath, - rect: { ...fresh.rect }, - }, - }; + // Clone the existing element verbatim and only swap the picture path + // (and clear identity fields so the host treats it as a fresh insert). + // Falling back to a minimal element is what was getting rejected as + // "Invalid API parameters" — picture elements require maxX/maxY/ + // status/recognizeResult etc. that we don't synthesize correctly. + let newElement: any; + if (raw) { + newElement = JSON.parse(JSON.stringify(raw)); + delete newElement.uuid; // host assigns a fresh one + delete newElement.numInPage; // host assigns a fresh one + if (newElement.picture) { + newElement.picture.picturePath = newPngPath; + } else { + newElement.picture = { picturePath: newPngPath, rect: { ...fresh.rect } }; + } + } else { + newElement = { + type: 200, + pageNum: fresh.page, + layerNum: fresh.layerNum, + picture: { + picturePath: newPngPath, + rect: { ...fresh.rect }, + }, + }; + } - // Insert the new picture FIRST. We learned the hard way that the - // opposite order (delete then insert) can leave the user with no - // embed at all if the insert step silently fails. We also avoid the - // modifyElements path entirely — the SDK accepted it on 0.7.4 logs - // but the displayed picture never repainted (the renderer caches the - // bitmap by element identity and a path-only change doesn't - // invalidate that cache). let insertOk = false; let insertErr: any = null; try { @@ -128,18 +146,13 @@ export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Pro } if (!insertOk) { - // Fallback: insertImage uses the host's default position so we - // lose the rect, but the user at least sees a fresh frame. They - // can re-position with the lasso afterwards. - console.log('[embedimage] insertElements failed (', insertErr, ') — falling back to insertImage'); - const img: any = await PluginNoteAPI.insertImage(newPngPath); - console.log('[embedimage] insertImage ->', JSON.stringify(img)); - if (!img || img.success === false) { - throw new Error(`insert failed (original embed kept): ${img?.error?.message ?? insertErr ?? 'unknown'}`); - } + // insertImage fallback is unreliable — it placed the new picture + // with pageNum=-1 in observed runs, so the user doesn't see it. + // Keep the original embed in that case. + console.log('[embedimage] insertElements failed (', insertErr, ') — leaving original embed in place'); + throw new Error(`insert failed (original embed kept): ${insertErr ?? 'unknown'}`); } - // Only delete the old embed now that something has been re-inserted. try { const del: any = await PluginFileAPI.deleteElements( fresh.notePath, fresh.page, [fresh.numInPage], @@ -147,7 +160,7 @@ export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Pro console.log('[embedimage] deleteElements ->', JSON.stringify(del)); } catch (e: any) { console.log('[embedimage] deleteElements threw:', e?.message ?? e); - // Non-fatal: user now sees two pictures, better than zero. + // Non-fatal: user has two pictures, better than zero. } try { diff --git a/src/screens/SendLasso.tsx b/src/screens/SendLasso.tsx index 8ff8eb7..66b8b58 100644 --- a/src/screens/SendLasso.tsx +++ b/src/screens/SendLasso.tsx @@ -36,9 +36,11 @@ export function SendLasso({ onClose }: { onClose: () => void }): React.JSX.Eleme (async () => { try { + console.log('[embedimage] SendLasso start'); setStatus('Loading config…'); const cfg = await loadStreamConfig(); const url = baseUrl(cfg); + console.log('[embedimage] SendLasso url=', url); if (!url) { setStatus('No Mac server set. Settings → Mac Capture Server.'); setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2500); @@ -46,42 +48,45 @@ export function SendLasso({ onClose }: { onClose: () => void }): React.JSX.Eleme } if (cancelled) return; setStatus('Exporting lasso…'); - // The plugin process can write into the pluginhost's own cache - // dir; /data/local/tmp is adb-shell-only so the SDK silently - // fails to write there. const tmpPath = `/data/user/0/com.ratta.supernote.pluginhost/cache/lasso_${Date.now()}.png`; + console.log('[embedimage] SendLasso generateLassoPreview ->', tmpPath); let res: any; try { res = await PluginCommAPI.generateLassoPreview(tmpPath); + console.log('[embedimage] SendLasso generateLassoPreview returned:', JSON.stringify(res)); } catch (e: any) { + console.log('[embedimage] SendLasso generateLassoPreview threw:', e?.message ?? e); setStatus(`Lasso export failed: ${e?.message ?? e}`); - setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2500); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 3000); return; } if (!res || res.success === false) { const msg = res?.error?.message ?? 'no lasso content'; + console.log('[embedimage] SendLasso lasso empty:', msg); setStatus(`Lasso empty: ${msg}\n(Lasso something first, then tap again.)`); - setTimeout(() => PluginManager.closePluginView().catch(() => {}), 3000); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 3500); return; } const resultPath: string = typeof res?.result === 'string' ? res.result : (res?.result?.path ?? tmpPath); + console.log('[embedimage] SendLasso resultPath=', resultPath); if (cancelled) return; setPreviewUri('file://' + resultPath); setStatus('Uploading to Mac…'); const name = `manta_${new Date().toISOString().replace(/[:.]/g, '-')}.png`; - await lanPostFile(`${url}/sketch?name=${encodeURIComponent(name)}`, resultPath, 'image/png', 30000); + console.log('[embedimage] SendLasso POST', `${url}/sketch?name=${name}`); + const out = await lanPostFile(`${url}/sketch?name=${encodeURIComponent(name)}`, resultPath, 'image/png', 30000); + console.log('[embedimage] SendLasso upload ok, response saved at', out); if (cancelled) return; setStatus(`Sent to Mac as ${name}`); setDone(true); - // Auto-close after a brief confirm so the user doesn't have to - // tap OK every time. setTimeout(() => PluginManager.closePluginView().catch(() => {}), 1500); } catch (e: any) { + console.log('[embedimage] SendLasso outer threw:', e?.message ?? e); if (cancelled) return; setStatus(`Failed: ${e?.message ?? e}`); - setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2500); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 3000); } })(); return () => { From 7e009911a667b95b44d1c28b9283c0c5e904346f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 13:54:03 +0000 Subject: [PATCH 18/28] Refresh: insert+move fallback; SendLasso: switch to generateNotePng MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cribbed approach from guibor/supernote-endpoint-lasso and Laumss/Inkling — neither uses generateLassoPreview (its sandbox semantics are flaky). SendLasso - Drop generateLassoPreview. Use the same pipeline Inkling/guibor use: getCurrentFilePath + getCurrentPageNum getLassoRect PluginFileAPI.generateNotePng({notePath, page, times:1, pngPath, type:1}) - Upload the rendered page PNG to /sketch with the lasso rect as query parameters (cropL/T/R/B). Mac crops server-side before saving. - Per-step console.log so the next adb log shows exactly which call succeeds or fails. Refresh embed - When cloning the existing element, also strip recognizeResult, status, contoursSrc, angles — those describe the OLD picture's derived data and confuse the host's validator. Keep maxX/maxY/ thickness/layout fields and the picture sub-object (now with the new picturePath and the fresh rect). - Add an insertImage-then-modifyElements-to-move fallback. When insertElements still rejects, insertImage places a new picture at the host's default position, then modifyElements moves it to the original rect. That avoids the previous failure mode where insertImage worked but the user couldn't see the picture (it landed off-page). Server - /sketch accepts optional cropL/T/R/B query params and crops the uploaded PNG to that rect with PIL before saving. Bounds are clamped to the image; out-of-bounds requests just save the whole page so we never lose data. Bump 0.7.6 -> 0.7.7 / versionCode 23 -> 24. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- PluginConfig.json | 4 +-- macapp/server.py | 35 ++++++++++++++++-- src/embedTracker.ts | 76 ++++++++++++++++++++++++++++----------- src/screens/SendLasso.tsx | 62 ++++++++++++++++++++------------ 4 files changed, 130 insertions(+), 47 deletions(-) diff --git a/PluginConfig.json b/PluginConfig.json index 9dd5c39..0ea68ee 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.7.6", - "versionCode": "23", + "versionName": "0.7.7", + "versionCode": "24", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/macapp/server.py b/macapp/server.py index 23ef48c..6f2963a 100644 --- a/macapp/server.py +++ b/macapp/server.py @@ -659,9 +659,40 @@ def sketch(): name = f"manta_{time.strftime('%Y%m%d_%H%M%S')}.png" if "/" in name or name.startswith("."): return jsonify({"error": "invalid name"}), 400 + + # If the plugin sent crop coordinates (the lasso rect, in note pixel + # coordinates that match the rendered page), crop to just that area. + # Otherwise save the whole page as-is. + try: + cl = int(request.args.get("cropL", "")) if request.args.get("cropL") else None + ct = int(request.args.get("cropT", "")) if request.args.get("cropT") else None + cr = int(request.args.get("cropR", "")) if request.args.get("cropR") else None + cb = int(request.args.get("cropB", "")) if request.args.get("cropB") else None + except ValueError: + cl = ct = cr = cb = None + + out_bytes = raw + if cl is not None and ct is not None and cr is not None and cb is not None and cr > cl and cb > ct: + try: + img = Image.open(io.BytesIO(raw)) + iw, ih = img.size + # Clamp to image bounds. + cl_c = max(0, min(iw, cl)) + ct_c = max(0, min(ih, ct)) + cr_c = max(0, min(iw, cr)) + cb_c = max(0, min(ih, cb)) + if cr_c > cl_c and cb_c > ct_c: + cropped = img.crop((cl_c, ct_c, cr_c, cb_c)) + buf = io.BytesIO() + cropped.save(buf, format="PNG", optimize=False) + out_bytes = buf.getvalue() + log(f"sketch cropped {iw}x{ih} -> {cr_c - cl_c}x{cb_c - ct_c}") + except Exception as e: # noqa: BLE001 + log(f"sketch crop failed (saving full page): {e}") + path = SKETCHES_DIR / name - path.write_bytes(raw) - log(f"sketch saved: {path}") + path.write_bytes(out_bytes) + log(f"sketch saved: {path} ({len(out_bytes)} bytes)") return jsonify({"ok": True, "path": str(path), "name": name}) diff --git a/src/embedTracker.ts b/src/embedTracker.ts index f0a5cab..02f0a1b 100644 --- a/src/embedTracker.ts +++ b/src/embedTracker.ts @@ -101,18 +101,22 @@ export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Pro rawKeys: raw ? Object.keys(raw) : null, }); - // Clone the existing element verbatim and only swap the picture path - // (and clear identity fields so the host treats it as a fresh insert). - // Falling back to a minimal element is what was getting rejected as - // "Invalid API parameters" — picture elements require maxX/maxY/ - // status/recognizeResult etc. that we don't synthesize correctly. + // Clone the existing element to keep maxX/maxY/thickness/layout fields + // intact, but strip everything that describes the OLD picture's data + // (uuid/numInPage are obvious; recognizeResult/status/contoursSrc/ + // angles are derived from the old picture and confuse the host). let newElement: any; if (raw) { newElement = JSON.parse(JSON.stringify(raw)); - delete newElement.uuid; // host assigns a fresh one - delete newElement.numInPage; // host assigns a fresh one + for (const k of [ + 'uuid', 'numInPage', 'recognizeResult', 'status', + 'contoursSrc', 'angles', + ]) { + delete newElement[k]; + } if (newElement.picture) { newElement.picture.picturePath = newPngPath; + newElement.picture.rect = { ...fresh.rect }; } else { newElement.picture = { picturePath: newPngPath, rect: { ...fresh.rect } }; } @@ -127,6 +131,7 @@ export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Pro }, }; } + console.log('[embedimage] replaceInPlace newElement keys:', Object.keys(newElement)); let insertOk = false; let insertErr: any = null; @@ -145,12 +150,39 @@ export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Pro console.log('[embedimage] insertElements threw:', insertErr); } + let movedTo: EmbedTrack | null = null; if (!insertOk) { - // insertImage fallback is unreliable — it placed the new picture - // with pageNum=-1 in observed runs, so the user doesn't see it. - // Keep the original embed in that case. - console.log('[embedimage] insertElements failed (', insertErr, ') — leaving original embed in place'); - throw new Error(`insert failed (original embed kept): ${insertErr ?? 'unknown'}`); + // Fallback: use insertImage (host picks default position, but it + // always works) then modifyElements to move the new picture to + // fresh.rect. modifyElements doesn't re-load a picture's bitmap, + // but it *does* honor rect updates, so this gives us refresh-in- + // place even when insertElements rejects our payload shape. + console.log('[embedimage] insertElements failed (', insertErr, ') — trying insertImage + move'); + const img: any = await PluginNoteAPI.insertImage(newPngPath); + console.log('[embedimage] insertImage ->', JSON.stringify(img)); + if (!img || img.success === false) { + throw new Error(`insert failed (original embed kept): ${img?.error?.message ?? insertErr ?? 'unknown'}`); + } + try { await PluginNoteAPI.saveCurrentNote(); } catch {} + // Get the new picture's identity so we can move it. + try { + const lastRes: any = await PluginFileAPI.getLastElement(); + const newEl = lastRes?.result ?? lastRes; + console.log('[embedimage] insertImage placed element:', JSON.stringify(newEl)?.slice(0, 300)); + if (newEl?.uuid && typeof newEl?.numInPage === 'number') { + const moveEl: any = { + ...newEl, + picture: { ...(newEl.picture ?? {}), rect: { ...fresh.rect } }, + }; + const moveRes: any = await PluginFileAPI.modifyElements( + fresh.notePath, fresh.page, [moveEl], + ); + console.log('[embedimage] modifyElements (move) ->', JSON.stringify(moveRes)); + movedTo = fromElement(fresh.notePath, fresh.page, newEl); + } + } catch (e: any) { + console.log('[embedimage] move-after-insertImage threw:', e?.message ?? e); + } } try { @@ -169,15 +201,17 @@ export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Pro console.log('[embedimage] saveCurrentNote threw:', e?.message ?? e); } - let newTrack: EmbedTrack | null = null; - try { - const lastRes: any = await PluginFileAPI.getLastElement(); - const el = lastRes?.result ?? lastRes; - console.log('[embedimage] getLastElement after replace ->', JSON.stringify(el)?.slice(0, 400)); - newTrack = fromElement(fresh.notePath, fresh.page, el); - } catch (e: any) { - console.log('[embedimage] getLastElement threw:', e?.message ?? e); - newTrack = null; + let newTrack: EmbedTrack | null = movedTo; + if (!newTrack) { + try { + const lastRes: any = await PluginFileAPI.getLastElement(); + const el = lastRes?.result ?? lastRes; + console.log('[embedimage] getLastElement after replace ->', JSON.stringify(el)?.slice(0, 400)); + newTrack = fromElement(fresh.notePath, fresh.page, el); + } catch (e: any) { + console.log('[embedimage] getLastElement threw:', e?.message ?? e); + newTrack = null; + } } if (newTrack) await saveEmbedTrack(newTrack).catch(() => {}); return newTrack; diff --git a/src/screens/SendLasso.tsx b/src/screens/SendLasso.tsx index 66b8b58..9132821 100644 --- a/src/screens/SendLasso.tsx +++ b/src/screens/SendLasso.tsx @@ -7,7 +7,7 @@ import { Text, View, } from 'react-native'; -import { PluginCommAPI, PluginManager } from 'sn-plugin-lib'; +import { PluginCommAPI, PluginFileAPI, PluginManager } from 'sn-plugin-lib'; import { lanPostFile } from '../imageProcessor'; import { baseUrl, loadStreamConfig } from '../storage'; import { theme } from '../ui/theme'; @@ -47,36 +47,54 @@ export function SendLasso({ onClose }: { onClose: () => void }): React.JSX.Eleme return; } if (cancelled) return; - setStatus('Exporting lasso…'); - const tmpPath = `/data/user/0/com.ratta.supernote.pluginhost/cache/lasso_${Date.now()}.png`; - console.log('[embedimage] SendLasso generateLassoPreview ->', tmpPath); - let res: any; + + // Inkling / guibor approach: don't use generateLassoPreview (its + // sandbox path semantics are flaky). Render the WHOLE PAGE to PNG + // via generateNotePng, fetch the lasso rect separately, and ship + // both to the Mac. The Mac can crop or display as-is. + setStatus('Reading note context…'); + const fpRes: any = await PluginCommAPI.getCurrentFilePath(); + const pgRes: any = await PluginCommAPI.getCurrentPageNum(); + const notePath = fpRes?.result ?? fpRes?.filePath ?? fpRes; + const page = pgRes?.result ?? pgRes?.pageNum ?? pgRes; + console.log('[embedimage] SendLasso ctx notePath=', notePath, 'page=', page); + if (typeof notePath !== 'string' || typeof page !== 'number') { + setStatus('Could not get current note/page.'); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2500); + return; + } + + let lassoRect: any = null; try { - res = await PluginCommAPI.generateLassoPreview(tmpPath); - console.log('[embedimage] SendLasso generateLassoPreview returned:', JSON.stringify(res)); + const lr: any = await PluginCommAPI.getLassoRect(); + if (lr?.success !== false && lr?.result) lassoRect = lr.result; + console.log('[embedimage] SendLasso getLassoRect ->', JSON.stringify(lassoRect)); } catch (e: any) { - console.log('[embedimage] SendLasso generateLassoPreview threw:', e?.message ?? e); - setStatus(`Lasso export failed: ${e?.message ?? e}`); - setTimeout(() => PluginManager.closePluginView().catch(() => {}), 3000); - return; + console.log('[embedimage] SendLasso getLassoRect threw:', e?.message ?? e); } - if (!res || res.success === false) { - const msg = res?.error?.message ?? 'no lasso content'; - console.log('[embedimage] SendLasso lasso empty:', msg); - setStatus(`Lasso empty: ${msg}\n(Lasso something first, then tap again.)`); + + setStatus('Rendering page to PNG…'); + const pngPath = `/data/user/0/com.ratta.supernote.pluginhost/cache/page_${Date.now()}.png`; + console.log('[embedimage] SendLasso generateNotePng ->', pngPath); + const gen: any = await PluginFileAPI.generateNotePng({ + notePath, page, times: 1, pngPath, type: 1, // 1 = white background + }); + console.log('[embedimage] SendLasso generateNotePng returned:', JSON.stringify(gen)); + if (!gen || gen.success === false) { + setStatus(`Render failed: ${gen?.error?.message ?? 'unknown'}`); setTimeout(() => PluginManager.closePluginView().catch(() => {}), 3500); return; } - const resultPath: string = - typeof res?.result === 'string' ? res.result : - (res?.result?.path ?? tmpPath); - console.log('[embedimage] SendLasso resultPath=', resultPath); if (cancelled) return; - setPreviewUri('file://' + resultPath); + setPreviewUri('file://' + pngPath); + setStatus('Uploading to Mac…'); const name = `manta_${new Date().toISOString().replace(/[:.]/g, '-')}.png`; - console.log('[embedimage] SendLasso POST', `${url}/sketch?name=${name}`); - const out = await lanPostFile(`${url}/sketch?name=${encodeURIComponent(name)}`, resultPath, 'image/png', 30000); + const qs = lassoRect + ? `?name=${encodeURIComponent(name)}&cropL=${lassoRect.left}&cropT=${lassoRect.top}&cropR=${lassoRect.right}&cropB=${lassoRect.bottom}` + : `?name=${encodeURIComponent(name)}`; + console.log('[embedimage] SendLasso POST', `${url}/sketch${qs}`); + const out = await lanPostFile(`${url}/sketch${qs}`, pngPath, 'image/png', 30000); console.log('[embedimage] SendLasso upload ok, response saved at', out); if (cancelled) return; setStatus(`Sent to Mac as ${name}`); From 92f48612bd170523049c7fe837a6b3eade8444db Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 14:02:42 +0000 Subject: [PATCH 19/28] Blend in Inkling: OCR-lasso-to-text + persistent file logger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lasso Recognize (Inkling's killer feature) - New lasso-toolbar button "Recognize" (id=5, type=2). - src/lassoRecognize.ts ports Inkling's pipeline: PluginCommAPI.getLassoElements -> partition into strokes / text boxes / pictures PluginCommAPI.recognizeElements(strokes, pageSize) -> recognized text PluginNoteAPI.insertText(text, rectBelowLastTextBox or belowLasso) - New RecognizeLasso screen — same Win95 progress dialog as Refresh, shows per-stage status + closes itself when done. Restricted to strokes/title/text editDataTypes so it doesn't appear on a pure picture lasso. Persistent file logger - Inkling logs every event to /sdcard/INBOX/localsend-plugin.log so it survives bridge tear-downs. Same idea here, written to /sdcard/EmbedImage/log.txt via a new native appendFile method. - src/util/FileLogger.ts mirrors every entry to console.log AND enqueues for batch flush to the file. Variadic raw() drop-in for the existing console.log call-sites. - Wired into embedTracker.replaceInPlace and SendLasso so the next refresh/send leaves a complete trace at /sdcard/EmbedImage/log.txt even if logcat is noisy. `adb pull /sdcard/EmbedImage/log.txt` to inspect. Bump 0.7.7 -> 0.8.0 / versionCode 24 -> 25. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- App.tsx | 7 + PluginConfig.json | 4 +- .../com/embedimage/ImageProcessorModule.kt | 15 ++ index.js | 12 +- src/embedTracker.ts | 29 ++-- src/imageProcessor.ts | 1 + src/lassoRecognize.ts | 110 ++++++++++++++ src/screens/RecognizeLasso.tsx | 137 ++++++++++++++++++ src/screens/SendLasso.tsx | 23 +-- src/types.ts | 3 +- src/util/FileLogger.ts | 71 +++++++++ 11 files changed, 383 insertions(+), 29 deletions(-) create mode 100644 src/lassoRecognize.ts create mode 100644 src/screens/RecognizeLasso.tsx create mode 100644 src/util/FileLogger.ts diff --git a/App.tsx b/App.tsx index ecce710..712eb95 100644 --- a/App.tsx +++ b/App.tsx @@ -5,6 +5,7 @@ import { BrowserScreen } from './src/screens/Browser'; import { CaptureScreen } from './src/screens/Capture'; import { DropInbox } from './src/screens/DropInbox'; import { PreviewScreen } from './src/screens/Preview'; +import { RecognizeLasso } from './src/screens/RecognizeLasso'; import { RefreshScreen } from './src/screens/Refresh'; import { SendLasso } from './src/screens/SendLasso'; import { SettingsScreen } from './src/screens/Settings'; @@ -16,6 +17,7 @@ import { DEFAULT_STREAM_CONFIG, Entry, Screen, StreamConfig } from './src/types' const BUTTON_REFRESH = 2; const BUTTON_DROP = 3; const BUTTON_LASSO_SEND = 4; +const BUTTON_LASSO_RECOGNIZE = 5; export default function App(): React.JSX.Element { const [screen, setScreen] = useState('browser'); @@ -34,6 +36,7 @@ export default function App(): React.JSX.Element { if (msg?.id === BUTTON_REFRESH) setScreen('refresh'); else if (msg?.id === BUTTON_DROP) setScreen('dropinbox'); else if (msg?.id === BUTTON_LASSO_SEND) setScreen('sendlasso'); + else if (msg?.id === BUTTON_LASSO_RECOGNIZE) setScreen('recognizelasso'); }, }); return () => { @@ -63,6 +66,10 @@ export default function App(): React.JSX.Element { return ; } + if (screen === 'recognizelasso') { + return ; + } + if (screen === 'preview' && selectedEntry) { return ; } diff --git a/PluginConfig.json b/PluginConfig.json index 0ea68ee..fd2caca 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.7.7", - "versionCode": "24", + "versionName": "0.8.0", + "versionCode": "25", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt b/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt index cf64162..dd8cab8 100644 --- a/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt +++ b/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt @@ -368,6 +368,21 @@ class ImageProcessorModule(reactContext: ReactApplicationContext) : } } + // Inkling-style file logger. Creates parent dirs on demand; appends + // UTF-8 with a trailing newline. Best-effort: callers should swallow + // failures (e.g. when /sdcard isn't writable on a locked device). + @ReactMethod + fun appendFile(path: String, text: String, promise: Promise) { + try { + val f = File(path) + f.parentFile?.mkdirs() + FileOutputStream(f, true).use { it.write((text + "\n").toByteArray(Charsets.UTF_8)) } + promise.resolve(true) + } catch (e: Throwable) { + promise.reject("E_APPEND", e.message ?: e.toString(), e) + } + } + @ReactMethod fun setConfigValue(key: String, value: String?, promise: Promise) { try { diff --git a/index.js b/index.js index facb901..3bbf8f3 100644 --- a/index.js +++ b/index.js @@ -12,7 +12,8 @@ PluginManager.init(); export const BUTTON_MAIN = 1; export const BUTTON_REFRESH = 2; export const BUTTON_DROP = 3; -export const BUTTON_LASSO_SEND = 4; // lasso-toolbar button (type=2) +export const BUTTON_LASSO_SEND = 4; // lasso toolbar — ship to Mac +export const BUTTON_LASSO_RECOGNIZE = 5; // lasso toolbar — OCR -> insert text const ICON = Image.resolveAssetSource(require('./assets/icon.png')).uri; @@ -58,6 +59,15 @@ PluginManager.registerButton(2, ['NOTE'], { editDataTypes: [0, 1, 2, 3, 4, 5], }).catch((e) => console.log('[embedimage] lasso button register skipped:', e)); +// Blended-in Inkling feature: lasso some handwriting, tap "Recognize", +// the plugin OCRs the strokes via PluginCommAPI.recognizeElements and +// drops the typed text back into the note. editDataTypes restricted +// to strokes/text (no point on pure pictures). +PluginManager.registerButton(2, ['NOTE'], { + ...baseBtn(BUTTON_LASSO_RECOGNIZE, 'Recognize'), + editDataTypes: [0, 1, 3], +}).catch((e) => console.log('[embedimage] recognize button register skipped:', e)); + PluginManager.registerButtonListener({ onButtonPress: (_msg) => { // Routing happens in App.tsx via the listener it registers there. diff --git a/src/embedTracker.ts b/src/embedTracker.ts index 02f0a1b..2e57668 100644 --- a/src/embedTracker.ts +++ b/src/embedTracker.ts @@ -1,6 +1,7 @@ import { PluginCommAPI, PluginFileAPI, PluginNoteAPI } from 'sn-plugin-lib'; import { saveEmbedTrack } from './storage'; import { EmbedTrack } from './types'; +import { FileLogger } from './util/FileLogger'; // Tracks the most recently embedded Picture element so the live-capture // flow can replace it in place when a new frame comes in. @@ -94,7 +95,7 @@ async function refreshTrackAndRaw( export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Promise { const { track: fresh, raw } = await refreshTrackAndRaw(track); - console.log('[embedimage] replaceInPlace start', { + FileLogger.raw('[embedimage] replaceInPlace start', { notePath: fresh.notePath, page: fresh.page, numInPage: fresh.numInPage, layerNum: fresh.layerNum, uuid: fresh.uuid, rect: fresh.rect, newPngPath, @@ -131,7 +132,7 @@ export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Pro }, }; } - console.log('[embedimage] replaceInPlace newElement keys:', Object.keys(newElement)); + FileLogger.raw('[embedimage] replaceInPlace newElement keys:', Object.keys(newElement)); let insertOk = false; let insertErr: any = null; @@ -139,7 +140,7 @@ export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Pro const ins: any = await PluginFileAPI.insertElements( fresh.notePath, fresh.page, [newElement], ); - console.log('[embedimage] insertElements ->', JSON.stringify(ins)); + FileLogger.raw('[embedimage] insertElements ->', JSON.stringify(ins)); if (ins && ins.success !== false) { insertOk = true; } else { @@ -147,7 +148,7 @@ export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Pro } } catch (e: any) { insertErr = e?.message ?? String(e); - console.log('[embedimage] insertElements threw:', insertErr); + FileLogger.raw('[embedimage] insertElements threw:', insertErr); } let movedTo: EmbedTrack | null = null; @@ -157,9 +158,9 @@ export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Pro // fresh.rect. modifyElements doesn't re-load a picture's bitmap, // but it *does* honor rect updates, so this gives us refresh-in- // place even when insertElements rejects our payload shape. - console.log('[embedimage] insertElements failed (', insertErr, ') — trying insertImage + move'); + FileLogger.raw('[embedimage] insertElements failed (', insertErr, ') — trying insertImage + move'); const img: any = await PluginNoteAPI.insertImage(newPngPath); - console.log('[embedimage] insertImage ->', JSON.stringify(img)); + FileLogger.raw('[embedimage] insertImage ->', JSON.stringify(img)); if (!img || img.success === false) { throw new Error(`insert failed (original embed kept): ${img?.error?.message ?? insertErr ?? 'unknown'}`); } @@ -168,7 +169,7 @@ export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Pro try { const lastRes: any = await PluginFileAPI.getLastElement(); const newEl = lastRes?.result ?? lastRes; - console.log('[embedimage] insertImage placed element:', JSON.stringify(newEl)?.slice(0, 300)); + FileLogger.raw('[embedimage] insertImage placed element:', JSON.stringify(newEl)?.slice(0, 300)); if (newEl?.uuid && typeof newEl?.numInPage === 'number') { const moveEl: any = { ...newEl, @@ -177,11 +178,11 @@ export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Pro const moveRes: any = await PluginFileAPI.modifyElements( fresh.notePath, fresh.page, [moveEl], ); - console.log('[embedimage] modifyElements (move) ->', JSON.stringify(moveRes)); + FileLogger.raw('[embedimage] modifyElements (move) ->', JSON.stringify(moveRes)); movedTo = fromElement(fresh.notePath, fresh.page, newEl); } } catch (e: any) { - console.log('[embedimage] move-after-insertImage threw:', e?.message ?? e); + FileLogger.raw('[embedimage] move-after-insertImage threw:', e?.message ?? e); } } @@ -189,16 +190,16 @@ export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Pro const del: any = await PluginFileAPI.deleteElements( fresh.notePath, fresh.page, [fresh.numInPage], ); - console.log('[embedimage] deleteElements ->', JSON.stringify(del)); + FileLogger.raw('[embedimage] deleteElements ->', JSON.stringify(del)); } catch (e: any) { - console.log('[embedimage] deleteElements threw:', e?.message ?? e); + FileLogger.raw('[embedimage] deleteElements threw:', e?.message ?? e); // Non-fatal: user has two pictures, better than zero. } try { await PluginNoteAPI.saveCurrentNote(); } catch (e: any) { - console.log('[embedimage] saveCurrentNote threw:', e?.message ?? e); + FileLogger.raw('[embedimage] saveCurrentNote threw:', e?.message ?? e); } let newTrack: EmbedTrack | null = movedTo; @@ -206,10 +207,10 @@ export async function replaceInPlace(track: EmbedTrack, newPngPath: string): Pro try { const lastRes: any = await PluginFileAPI.getLastElement(); const el = lastRes?.result ?? lastRes; - console.log('[embedimage] getLastElement after replace ->', JSON.stringify(el)?.slice(0, 400)); + FileLogger.raw('[embedimage] getLastElement after replace ->', JSON.stringify(el)?.slice(0, 400)); newTrack = fromElement(fresh.notePath, fresh.page, el); } catch (e: any) { - console.log('[embedimage] getLastElement threw:', e?.message ?? e); + FileLogger.raw('[embedimage] getLastElement threw:', e?.message ?? e); newTrack = null; } } diff --git a/src/imageProcessor.ts b/src/imageProcessor.ts index 733ccab..829bd93 100644 --- a/src/imageProcessor.ts +++ b/src/imageProcessor.ts @@ -33,6 +33,7 @@ type ImageProcessorNative = { contentType: string, timeoutMs: number, ) => Promise; + appendFile: (path: string, text: string) => Promise; cleanupCache: () => Promise; getConfigValue: (key: string) => Promise; setConfigValue: (key: string, value: string | null) => Promise; diff --git a/src/lassoRecognize.ts b/src/lassoRecognize.ts new file mode 100644 index 0000000..bd4d058 --- /dev/null +++ b/src/lassoRecognize.ts @@ -0,0 +1,110 @@ +import { PluginCommAPI, PluginFileAPI } from 'sn-plugin-lib'; +import { FileLogger } from './util/FileLogger'; + +// Inkling-style lasso → text extraction. Picks up any selected handwritten +// strokes, runs the host's OCR (recognizeElements), and returns the +// recognized text plus a suggested insertion rect (below the last text +// box in the selection, if any, otherwise below the lasso itself). + +export type RecognizeResult = { + text: string; + insertRect?: { left: number; top: number; right: number; bottom: number }; + stats: { strokes: number; textBoxes: number; pictures: number; others: number }; +}; + +export async function recognizeLasso(): Promise { + const out: RecognizeResult = { + text: '', + stats: { strokes: 0, textBoxes: 0, pictures: 0, others: 0 }, + }; + + const lassoRes: any = await PluginCommAPI.getLassoElements(); + FileLogger.log('Recognize', 'getLassoElements ->', { success: lassoRes?.success, count: Array.isArray(lassoRes?.result) ? lassoRes.result.length : 0 }); + if (!lassoRes?.success || !Array.isArray(lassoRes?.result)) { + throw new Error(lassoRes?.error?.message ?? 'getLassoElements failed'); + } + const elements: any[] = lassoRes.result; + + const strokes: any[] = []; + const textParts: string[] = []; + let lastTextBoxRect: { left: number; top: number; right: number; bottom: number } | undefined; + + for (const el of elements) { + switch (el?.type) { + case 0: // stroke + out.stats.strokes++; + strokes.push(el); + break; + case 500: + case 501: + case 502: { // text boxes + out.stats.textBoxes++; + const content: string = el.textBox?.textContentFull ?? ''; + if (content.trim()) textParts.push(content.trim()); + const r = el.textBox?.textRect; + if (r && (!lastTextBoxRect || r.bottom > lastTextBoxRect.bottom)) { + lastTextBoxRect = { left: r.left, top: r.top, right: r.right, bottom: r.bottom }; + } + break; + } + case 200: + out.stats.pictures++; + break; + default: + out.stats.others++; + break; + } + } + FileLogger.log('Recognize', `lasso stats`, out.stats); + + // OCR the handwritten strokes (if any) via the host's recognizer. + if (strokes.length > 0) { + try { + const fpRes: any = await PluginCommAPI.getCurrentFilePath(); + const pgRes: any = await PluginCommAPI.getCurrentPageNum(); + const notePath = fpRes?.result ?? fpRes?.filePath ?? fpRes; + const page = pgRes?.result ?? pgRes?.pageNum ?? pgRes; + if (typeof notePath === 'string' && typeof page === 'number') { + const psRes: any = await PluginFileAPI.getPageSize(notePath, page); + FileLogger.log('Recognize', 'getPageSize ->', psRes); + if (psRes?.success && psRes.result) { + const ocr: any = await PluginCommAPI.recognizeElements(strokes, psRes.result); + FileLogger.log('Recognize', 'recognizeElements ->', { success: ocr?.success, len: typeof ocr?.result === 'string' ? ocr.result.length : 0 }); + if (ocr?.success && typeof ocr.result === 'string' && ocr.result.trim()) { + textParts.push(ocr.result.trim()); + } + } + } + } catch (e: any) { + FileLogger.log('Recognize', 'OCR threw', e?.message ?? String(e)); + } + } + + out.text = textParts.join('\n'); + + // Pick an insertion rect. Prefer right below the last text box; fall + // back to right below the lasso bounding box. + try { + if (lastTextBoxRect) { + out.insertRect = { + left: lastTextBoxRect.left, + top: lastTextBoxRect.bottom + 10, + right: lastTextBoxRect.right, + bottom: lastTextBoxRect.bottom + 90, + }; + } else { + const lr: any = await PluginCommAPI.getLassoRect(); + const r = lr?.result; + if (r) { + out.insertRect = { + left: r.left, top: r.bottom + 10, + right: r.right, bottom: r.bottom + 90, + }; + } + } + } catch { + // insertRect optional; caller can fall back. + } + + return out; +} diff --git a/src/screens/RecognizeLasso.tsx b/src/screens/RecognizeLasso.tsx new file mode 100644 index 0000000..74c2b82 --- /dev/null +++ b/src/screens/RecognizeLasso.tsx @@ -0,0 +1,137 @@ +import React, { useEffect, useState } from 'react'; +import { Animated, Easing, SafeAreaView, StyleSheet, Text, View } from 'react-native'; +import { PluginManager, PluginNoteAPI } from 'sn-plugin-lib'; +import { recognizeLasso } from '../lassoRecognize'; +import { theme } from '../ui/theme'; +import { TitleBar, Win95Frame, Win95InsetPanel } from '../ui/Win95'; +import { FileLogger } from '../util/FileLogger'; + +// Inkling's killer feature, blended in: lasso some handwriting, tap +// "Recognize" in the lasso menu, it OCRs the strokes and drops typed +// text into the note. Headless-ish flow like Refresh. + +const PROGRESS_SEGMENTS = 16; + +export function RecognizeLasso(): React.JSX.Element { + const [status, setStatus] = useState('Recognizing lasso…'); + const tick = React.useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.loop( + Animated.timing(tick, { + toValue: PROGRESS_SEGMENTS, duration: 1600, + easing: Easing.linear, useNativeDriver: false, + }), + ).start(); + }, [tick]); + + useEffect(() => { + let cancelled = false; + const WATCHDOG_MS = 25000; + const watchdog = setTimeout(() => { + if (cancelled) return; + cancelled = true; + setStatus('Timed out. Closing…'); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 1200); + }, WATCHDOG_MS); + + (async () => { + try { + FileLogger.log('Recognize', 'screen mounted'); + setStatus('Reading lasso content…'); + const res = await recognizeLasso(); + FileLogger.log('Recognize', 'result', { text: res.text, stats: res.stats, hasRect: !!res.insertRect }); + if (cancelled) return; + + if (!res.text) { + setStatus( + res.stats.strokes === 0 + ? 'Nothing to recognize.\nLasso some handwriting first.' + : 'OCR returned empty. Try a clearer hand.', + ); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2800); + return; + } + + setStatus(`Inserting (${res.text.length} chars)…`); + const rect = res.insertRect ?? { left: 200, top: 200, right: 1800, bottom: 290 }; + const ins: any = await PluginNoteAPI.insertText({ + textContentFull: res.text, + textRect: rect, + fontSize: 36, + textAlign: 0, + textBold: 0, + textItalics: 0, + textFrameWidthType: 0, + textFrameStyle: 0, + textEditable: 1, + } as any); + FileLogger.log('Recognize', 'insertText ->', ins); + if (!ins || ins.success === false) { + setStatus(`Insert failed: ${ins?.error?.message ?? 'unknown'}`); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 3000); + return; + } + try { await PluginNoteAPI.saveCurrentNote(); } catch {} + if (cancelled) return; + setStatus(`Recognized ${res.text.length} chars`); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 800); + } catch (e: any) { + FileLogger.log('Recognize', 'threw', e?.message ?? String(e)); + if (cancelled) return; + setStatus(`Failed: ${e?.message ?? e}`); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2800); + } + })(); + return () => { + cancelled = true; + clearTimeout(watchdog); + }; + }, []); + + const segIndex = tick.interpolate({ + inputRange: [0, PROGRESS_SEGMENTS], outputRange: [0, PROGRESS_SEGMENTS], + }); + + return ( + + + + + Recognizing handwriting… + + + {Array.from({ length: PROGRESS_SEGMENTS }).map((_, i) => ( + + ))} + + + {status} + + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: theme.bg }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16 }, + dialog: { padding: 16, minWidth: 320, gap: 12 }, + heading: { fontFamily: 'VT323', fontSize: 22, color: theme.text }, + progressInset: { padding: 4 }, + progressRow: { flexDirection: 'row', gap: 2 }, + segment: { flex: 1, height: 16, backgroundColor: theme.titleBg }, + msg: { fontFamily: 'VT323', fontSize: 16, color: theme.textMuted, textAlign: 'center' }, +}); diff --git a/src/screens/SendLasso.tsx b/src/screens/SendLasso.tsx index 9132821..ab38825 100644 --- a/src/screens/SendLasso.tsx +++ b/src/screens/SendLasso.tsx @@ -11,7 +11,8 @@ import { PluginCommAPI, PluginFileAPI, PluginManager } from 'sn-plugin-lib'; import { lanPostFile } from '../imageProcessor'; import { baseUrl, loadStreamConfig } from '../storage'; import { theme } from '../ui/theme'; -import { StatusBar, TitleBar, Win95Button, Win95Frame, Win95InsetPanel } from '../ui/Win95'; +import { TitleBar, Win95Button, Win95Frame, Win95InsetPanel } from '../ui/Win95'; +import { FileLogger } from '../util/FileLogger'; // Headless-ish: triggered by the "Send Lasso to Mac" sidebar button. // Generates a PNG of the currently-lassoed elements via the SDK, POSTs @@ -36,11 +37,11 @@ export function SendLasso({ onClose }: { onClose: () => void }): React.JSX.Eleme (async () => { try { - console.log('[embedimage] SendLasso start'); + FileLogger.raw('[embedimage] SendLasso start'); setStatus('Loading config…'); const cfg = await loadStreamConfig(); const url = baseUrl(cfg); - console.log('[embedimage] SendLasso url=', url); + FileLogger.raw('[embedimage] SendLasso url=', url); if (!url) { setStatus('No Mac server set. Settings → Mac Capture Server.'); setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2500); @@ -57,7 +58,7 @@ export function SendLasso({ onClose }: { onClose: () => void }): React.JSX.Eleme const pgRes: any = await PluginCommAPI.getCurrentPageNum(); const notePath = fpRes?.result ?? fpRes?.filePath ?? fpRes; const page = pgRes?.result ?? pgRes?.pageNum ?? pgRes; - console.log('[embedimage] SendLasso ctx notePath=', notePath, 'page=', page); + FileLogger.raw('[embedimage] SendLasso ctx notePath=', notePath, 'page=', page); if (typeof notePath !== 'string' || typeof page !== 'number') { setStatus('Could not get current note/page.'); setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2500); @@ -68,18 +69,18 @@ export function SendLasso({ onClose }: { onClose: () => void }): React.JSX.Eleme try { const lr: any = await PluginCommAPI.getLassoRect(); if (lr?.success !== false && lr?.result) lassoRect = lr.result; - console.log('[embedimage] SendLasso getLassoRect ->', JSON.stringify(lassoRect)); + FileLogger.raw('[embedimage] SendLasso getLassoRect ->', JSON.stringify(lassoRect)); } catch (e: any) { - console.log('[embedimage] SendLasso getLassoRect threw:', e?.message ?? e); + FileLogger.raw('[embedimage] SendLasso getLassoRect threw:', e?.message ?? e); } setStatus('Rendering page to PNG…'); const pngPath = `/data/user/0/com.ratta.supernote.pluginhost/cache/page_${Date.now()}.png`; - console.log('[embedimage] SendLasso generateNotePng ->', pngPath); + FileLogger.raw('[embedimage] SendLasso generateNotePng ->', pngPath); const gen: any = await PluginFileAPI.generateNotePng({ notePath, page, times: 1, pngPath, type: 1, // 1 = white background }); - console.log('[embedimage] SendLasso generateNotePng returned:', JSON.stringify(gen)); + FileLogger.raw('[embedimage] SendLasso generateNotePng returned:', JSON.stringify(gen)); if (!gen || gen.success === false) { setStatus(`Render failed: ${gen?.error?.message ?? 'unknown'}`); setTimeout(() => PluginManager.closePluginView().catch(() => {}), 3500); @@ -93,15 +94,15 @@ export function SendLasso({ onClose }: { onClose: () => void }): React.JSX.Eleme const qs = lassoRect ? `?name=${encodeURIComponent(name)}&cropL=${lassoRect.left}&cropT=${lassoRect.top}&cropR=${lassoRect.right}&cropB=${lassoRect.bottom}` : `?name=${encodeURIComponent(name)}`; - console.log('[embedimage] SendLasso POST', `${url}/sketch${qs}`); + FileLogger.raw('[embedimage] SendLasso POST', `${url}/sketch${qs}`); const out = await lanPostFile(`${url}/sketch${qs}`, pngPath, 'image/png', 30000); - console.log('[embedimage] SendLasso upload ok, response saved at', out); + FileLogger.raw('[embedimage] SendLasso upload ok, response saved at', out); if (cancelled) return; setStatus(`Sent to Mac as ${name}`); setDone(true); setTimeout(() => PluginManager.closePluginView().catch(() => {}), 1500); } catch (e: any) { - console.log('[embedimage] SendLasso outer threw:', e?.message ?? e); + FileLogger.raw('[embedimage] SendLasso outer threw:', e?.message ?? e); if (cancelled) return; setStatus(`Failed: ${e?.message ?? e}`); setTimeout(() => PluginManager.closePluginView().catch(() => {}), 3000); diff --git a/src/types.ts b/src/types.ts index dd2f69b..0a1cd75 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,7 +9,8 @@ export type Screen = | 'refresh' | 'sourcepicker' | 'dropinbox' - | 'sendlasso'; + | 'sendlasso' + | 'recognizelasso'; export type DitherMode = 'none' | 'fs1' | 'fs4' | 'atkinson'; diff --git a/src/util/FileLogger.ts b/src/util/FileLogger.ts new file mode 100644 index 0000000..2317930 --- /dev/null +++ b/src/util/FileLogger.ts @@ -0,0 +1,71 @@ +import { ImageProcessor } from '../imageProcessor'; + +// Inkling-inspired persistent logger. Every call still hits console.log +// (so adb logcat keeps working) but also appends to a flat file the user +// can pull with `adb pull /sdcard/EmbedImage/log.txt`. Crucial when the +// RN bridge dies before logcat catches up. +const LOG_FILE = '/sdcard/EmbedImage/log.txt'; +const MAX_QUEUED = 200; + +let queued: string[] = []; +let flushing = false; + +function ts(): string { + const d = new Date(); + const pad = (n: number, w = 2) => String(n).padStart(w, '0'); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`; +} + +async function flush(): Promise { + if (flushing || queued.length === 0) return; + flushing = true; + const batch = queued.join('\n'); + queued = []; + try { + if (ImageProcessor?.appendFile) { + await ImageProcessor.appendFile(LOG_FILE, batch); + } + } catch { + // best-effort; if /sdcard isn't writable we just lose this batch. + } finally { + flushing = false; + if (queued.length > 0) flush(); + } +} + +function enqueue(line: string) { + queued.push(line); + if (queued.length > MAX_QUEUED) queued = queued.slice(-MAX_QUEUED); + // Fire-and-forget; we never want logging to block the caller. + flush(); +} + +export const FileLogger = { + path: LOG_FILE, + + log(tag: string, msg: string, extra?: any): void { + const formatted = extra !== undefined + ? `${ts()} [${tag}] ${msg} ${typeof extra === 'string' ? extra : safeStringify(extra)}` + : `${ts()} [${tag}] ${msg}`; + console.log(`[embedimage:${tag}]`, msg, extra ?? ''); + enqueue(formatted); + }, + + // Variadic drop-in for "this used to be console.log(a, b, c)". + raw(...parts: any[]): void { + console.log(...parts); + const stringified = parts + .map((p) => (typeof p === 'string' ? p : safeStringify(p))) + .join(' '); + enqueue(`${ts()} ${stringified}`); + }, +}; + +function safeStringify(obj: any): string { + try { + const s = JSON.stringify(obj); + return s.length > 800 ? s.slice(0, 800) + '…' : s; + } catch { + return String(obj); + } +} From 404b392100489a59f5f8698907c8825183f5db4f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 14:14:07 +0000 Subject: [PATCH 20/28] =?UTF-8?q?Send=20to=20Mac=20via=20jpmoo's=20lasso?= =?UTF-8?q?=20=E2=86=92=20sticker=20=E2=86=92=20PNG=20pipeline=20+=20showT?= =?UTF-8?q?ype:0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lasso→Mac was failing because generateLassoPreview never reliably wrote a file. The actual working API (per jpmoo/lassoexport) is: PluginCommAPI.saveStickerByLasso(stickerPath) // .sticker file PluginCommAPI.getStickerSize(stickerPath) // {width, height} PluginCommAPI.generateStickerThumbnail(stickerPath, pngPath, size) src/lassoExport.ts implements this end-to-end: - Uses PluginManager.getPluginDirPath() as the writable scratch dir. - Clears the element cache + any stale .sticker files before saving. - Derives a base filename from the note name. - Uploads the resulting PNG to the Mac's /sketch endpoint. Truly headless button (showType:0) - The Send to Mac lasso button is now showType:0, so tapping it never opens a plugin view. The button-press handler in index.js calls runSendLassoToMac() directly. Feedback comes via Android Toast on success and NativeUIUtils.showRattaDialog on failure — same pattern jpmoo uses. - SendLasso screen and 'sendlasso' route deleted (not needed anymore). Recognize keeps its progress screen because OCR takes longer and a spinner is useful. PNG / JPEG format selection - StreamConfig gains lassoFormat: 'png' | 'jpg'. Settings has a two-button toggle next to Resolution multiplier. - Manta always uploads PNG bytes; ?format=jpg in the query string tells the Mac to re-encode and rename the file accordingly. PNG keeps transparency, JPEG is smaller. Bump 0.8.0 -> 0.8.1 / versionCode 25 -> 26. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- App.tsx | 10 +-- PluginConfig.json | 4 +- index.js | 23 +++++- macapp/server.py | 42 ++++++++++- src/lassoExport.ts | 125 +++++++++++++++++++++++++++++++ src/screens/SendLasso.tsx | 150 -------------------------------------- src/screens/Settings.tsx | 32 +++++++- src/storage.ts | 2 + src/types.ts | 7 +- 9 files changed, 224 insertions(+), 171 deletions(-) create mode 100644 src/lassoExport.ts delete mode 100644 src/screens/SendLasso.tsx diff --git a/App.tsx b/App.tsx index 712eb95..9263288 100644 --- a/App.tsx +++ b/App.tsx @@ -7,16 +7,15 @@ import { DropInbox } from './src/screens/DropInbox'; import { PreviewScreen } from './src/screens/Preview'; import { RecognizeLasso } from './src/screens/RecognizeLasso'; import { RefreshScreen } from './src/screens/Refresh'; -import { SendLasso } from './src/screens/SendLasso'; import { SettingsScreen } from './src/screens/Settings'; import { SourcePicker } from './src/screens/SourcePicker'; import { baseUrl, loadStreamConfig } from './src/storage'; import { DEFAULT_STREAM_CONFIG, Entry, Screen, StreamConfig } from './src/types'; -// Must match index.js. +// Must match index.js. BUTTON_LASSO_SEND (4) is showType:0 — runs +// headlessly from index.js's button handler — so no route entry here. const BUTTON_REFRESH = 2; const BUTTON_DROP = 3; -const BUTTON_LASSO_SEND = 4; const BUTTON_LASSO_RECOGNIZE = 5; export default function App(): React.JSX.Element { @@ -35,7 +34,6 @@ export default function App(): React.JSX.Element { onButtonPress: (msg: any) => { if (msg?.id === BUTTON_REFRESH) setScreen('refresh'); else if (msg?.id === BUTTON_DROP) setScreen('dropinbox'); - else if (msg?.id === BUTTON_LASSO_SEND) setScreen('sendlasso'); else if (msg?.id === BUTTON_LASSO_RECOGNIZE) setScreen('recognizelasso'); }, }); @@ -62,10 +60,6 @@ export default function App(): React.JSX.Element { return ; } - if (screen === 'sendlasso') { - return ; - } - if (screen === 'recognizelasso') { return ; } diff --git a/PluginConfig.json b/PluginConfig.json index fd2caca..9a19e23 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.8.0", - "versionCode": "25", + "versionName": "0.8.1", + "versionCode": "26", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/index.js b/index.js index 3bbf8f3..b7b62cc 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,8 @@ import { AppRegistry, Image } from 'react-native'; import { PluginManager } from 'sn-plugin-lib'; import App from './App'; import { name as appName } from './app.json'; +import { runSendLassoToMac } from './src/lassoExport'; +import { loadStreamConfig } from './src/storage'; AppRegistry.registerComponent(appName, () => App); @@ -54,22 +56,37 @@ PluginManager.registerButton(1, ['NOTE'], baseBtn(BUTTON_DROP, 'Drop Inbox')) // the user opens the "..." menu and the menu crashes the note app. // editDataTypes values per the SDK: // 0 handwritten strokes 1 title 2 image 3 text 4 link 5 shapes +// +// showType: 0 means "headless" — no plugin view opens when the button +// is pressed. Action runs straight from the button-press handler and +// reports back via Toast / Ratta dialog. This is the pattern from +// jpmoo/lassoexport, which is the only one that works for a real +// lasso → file pipeline (using saveStickerByLasso underneath). PluginManager.registerButton(2, ['NOTE'], { ...baseBtn(BUTTON_LASSO_SEND, 'Send to Mac'), editDataTypes: [0, 1, 2, 3, 4, 5], + showType: 0, }).catch((e) => console.log('[embedimage] lasso button register skipped:', e)); // Blended-in Inkling feature: lasso some handwriting, tap "Recognize", // the plugin OCRs the strokes via PluginCommAPI.recognizeElements and // drops the typed text back into the note. editDataTypes restricted -// to strokes/text (no point on pure pictures). +// to strokes/text (no point on pure pictures). showType: 1 because the +// recognize flow shows a progress dialog (OCR can take seconds). PluginManager.registerButton(2, ['NOTE'], { ...baseBtn(BUTTON_LASSO_RECOGNIZE, 'Recognize'), editDataTypes: [0, 1, 3], }).catch((e) => console.log('[embedimage] recognize button register skipped:', e)); PluginManager.registerButtonListener({ - onButtonPress: (_msg) => { - // Routing happens in App.tsx via the listener it registers there. + onButtonPress: (msg) => { + // showType:0 buttons don't open a view, so we handle their action + // here in the headless context. The other buttons (showType:1) are + // routed by App.tsx after the view opens. + if (msg?.id === BUTTON_LASSO_SEND) { + loadStreamConfig() + .then((cfg) => runSendLassoToMac(cfg.lassoFormat ?? 'png')) + .catch(() => {}); + } }, }); diff --git a/macapp/server.py b/macapp/server.py index 6f2963a..1dda005 100644 --- a/macapp/server.py +++ b/macapp/server.py @@ -671,7 +671,22 @@ def sketch(): except ValueError: cl = ct = cr = cb = None + # Optional output format. The Manta always uploads PNG bytes; if the + # user picked JPEG in Settings, we re-encode here. + fmt_q = (request.args.get("format", "") or "").lower().strip() + fmt: str + if fmt_q in ("jpg", "jpeg"): + fmt = "JPEG" + elif fmt_q == "png": + fmt = "PNG" + elif name.lower().endswith((".jpg", ".jpeg")): + fmt = "JPEG" + else: + fmt = "PNG" + out_bytes = raw + needs_reencode = (fmt == "JPEG") + if cl is not None and ct is not None and cr is not None and cb is not None and cr > cl and cb > ct: try: img = Image.open(io.BytesIO(raw)) @@ -684,16 +699,35 @@ def sketch(): if cr_c > cl_c and cb_c > ct_c: cropped = img.crop((cl_c, ct_c, cr_c, cb_c)) buf = io.BytesIO() - cropped.save(buf, format="PNG", optimize=False) + if fmt == "JPEG": + cropped.convert("RGB").save(buf, format="JPEG", quality=92, optimize=True) + else: + cropped.save(buf, format="PNG", optimize=False) out_bytes = buf.getvalue() - log(f"sketch cropped {iw}x{ih} -> {cr_c - cl_c}x{cb_c - ct_c}") + needs_reencode = False + log(f"sketch cropped {iw}x{ih} -> {cr_c - cl_c}x{cb_c - ct_c} ({fmt})") except Exception as e: # noqa: BLE001 log(f"sketch crop failed (saving full page): {e}") + if needs_reencode: + try: + img = Image.open(io.BytesIO(raw)) + buf = io.BytesIO() + img.convert("RGB").save(buf, format="JPEG", quality=92, optimize=True) + out_bytes = buf.getvalue() + except Exception as e: # noqa: BLE001 + log(f"sketch JPEG encode failed (saving as PNG): {e}") + + # Make sure the filename extension matches the actual format. + if fmt == "JPEG" and not name.lower().endswith((".jpg", ".jpeg")): + name = name.rsplit(".", 1)[0] + ".jpg" + elif fmt == "PNG" and not name.lower().endswith(".png"): + name = name.rsplit(".", 1)[0] + ".png" + path = SKETCHES_DIR / name path.write_bytes(out_bytes) - log(f"sketch saved: {path} ({len(out_bytes)} bytes)") - return jsonify({"ok": True, "path": str(path), "name": name}) + log(f"sketch saved: {path} ({len(out_bytes)} bytes, {fmt})") + return jsonify({"ok": True, "path": str(path), "name": name, "format": fmt}) # ---- Adjustment presets ---- diff --git a/src/lassoExport.ts b/src/lassoExport.ts new file mode 100644 index 0000000..5772d5f --- /dev/null +++ b/src/lassoExport.ts @@ -0,0 +1,125 @@ +import { FileUtils, NativeUIUtils, PluginCommAPI, PluginManager } from 'sn-plugin-lib'; +import { lanPostFile } from './imageProcessor'; +import { baseUrl, loadStreamConfig } from './storage'; +import { FileLogger } from './util/FileLogger'; + +// Cribbed from jpmoo/lassoexport. The reliable lasso → PNG pipeline: +// PluginCommAPI.saveStickerByLasso(stickerPath) +// PluginCommAPI.getStickerSize(stickerPath) -> {width, height} +// PluginCommAPI.generateStickerThumbnail(stickerPath, pngPath, size) +// `.sticker` is the host's internal lasso archive; the thumbnail call +// rasterizes it to a PNG we can read like any other file. +// +// We then upload that PNG to the Mac's /sketch endpoint. The Mac decides +// whether to save as PNG or JPEG based on the ?format=… query string. + +function unwrap(value: any, what: string): T { + if (!value || value.success === false) { + const msg = value?.error?.message ?? `${what} failed`; + throw new Error(msg); + } + return value.result as T; +} + +function deriveBaseName(notePath: string): string { + const last = notePath.split('/').pop() || 'note'; + const noExt = last.replace(/\.[^.]+$/, ''); + const safe = noExt.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^_+|_+$/g, ''); + return safe.length > 0 ? safe : 'note'; +} + +async function exportLassoToPng(): Promise { + const pluginDir = await PluginManager.getPluginDirPath(); + if (!pluginDir) throw new Error('cannot resolve plugin directory'); + const trimmedPlugin = pluginDir.replace(/\/+$/, ''); + + // Clear stale lasso data and any leftover .sticker files from prior runs. + try { PluginCommAPI.clearElementCache(); } catch {} + try { + const existing: any = await FileUtils.listFiles(pluginDir); + if (Array.isArray(existing)) { + for (const entry of existing) { + const name = typeof entry === 'string' ? entry : entry?.path; + if (typeof name === 'string' && name.endsWith('.sticker')) { + try { await FileUtils.deleteFile(name.startsWith('/') ? name : `${trimmedPlugin}/${name}`); } catch {} + } + } + } + } catch {} + + let baseName = 'note'; + try { + const notePath = unwrap(await PluginCommAPI.getCurrentFilePath(), 'getCurrentFilePath'); + baseName = deriveBaseName(notePath); + } catch {} + + const stamp = Date.now(); + const stickerPath = `${trimmedPlugin}/sticker-${stamp}.sticker`; + const pngPath = `${trimmedPlugin}/lasso-${baseName}-${stamp}.png`; + + FileLogger.raw('[embedimage] exportLassoToPng saveStickerByLasso ->', stickerPath); + unwrap(await PluginCommAPI.saveStickerByLasso(stickerPath), 'saveStickerByLasso'); + if (!(await FileUtils.exists(stickerPath))) { + throw new Error('sticker file was not written (lasso may be empty)'); + } + + const size = unwrap<{ width: number; height: number }>( + await PluginCommAPI.getStickerSize(stickerPath), + 'getStickerSize', + ); + if (!size?.width || !size?.height) { + throw new Error(`getStickerSize returned ${JSON.stringify(size)}`); + } + FileLogger.raw('[embedimage] exportLassoToPng size=', size); + + unwrap( + await PluginCommAPI.generateStickerThumbnail(stickerPath, pngPath, size), + 'generateStickerThumbnail', + ); + if (!(await FileUtils.exists(pngPath))) { + throw new Error('PNG was not written'); + } + + // Drop the .sticker; we only needed the PNG. + try { await FileUtils.deleteFile(stickerPath); } catch {} + + return pngPath; +} + +export type LassoFormat = 'png' | 'jpg'; + +// Headless entry point — runs from index.js with no view opening. +// Reports progress + result via Android Toast and an error dialog so +// the user gets feedback without a React surface. +export async function runSendLassoToMac(format: LassoFormat = 'png'): Promise { + FileLogger.raw('[embedimage] runSendLassoToMac start format=', format); + try { + const cfg = await loadStreamConfig(); + const url = baseUrl(cfg); + if (!url) { + try { NativeUIUtils.showRattaDialog('No Mac server set.\nOpen Embed Image → Settings.', '', 'OK', false); } catch {} + return; + } + + const pngPath = await exportLassoToPng(); + FileLogger.raw('[embedimage] runSendLassoToMac pngPath=', pngPath); + + // Filename extension drives format selection on the Mac side. + const baseStamp = new Date().toISOString().replace(/[:.]/g, '-'); + const name = `manta_${baseStamp}.${format}`; + const qs = `?name=${encodeURIComponent(name)}&format=${format}`; + const url2 = `${url}/sketch${qs}`; + FileLogger.raw('[embedimage] runSendLassoToMac POST', url2); + await lanPostFile(url2, pngPath, 'image/png', 30000); + FileLogger.raw('[embedimage] runSendLassoToMac done'); + + try { + const ToastAndroid = require('react-native').ToastAndroid; + ToastAndroid.show(`Sent to Mac as ${name}`, ToastAndroid.LONG); + } catch {} + } catch (e: any) { + const msg = e?.message ?? String(e); + FileLogger.raw('[embedimage] runSendLassoToMac failed:', msg); + try { NativeUIUtils.showRattaDialog(`Send failed: ${msg}`, '', 'OK', false); } catch {} + } +} diff --git a/src/screens/SendLasso.tsx b/src/screens/SendLasso.tsx deleted file mode 100644 index ab38825..0000000 --- a/src/screens/SendLasso.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - ActivityIndicator, - Image, - SafeAreaView, - StyleSheet, - Text, - View, -} from 'react-native'; -import { PluginCommAPI, PluginFileAPI, PluginManager } from 'sn-plugin-lib'; -import { lanPostFile } from '../imageProcessor'; -import { baseUrl, loadStreamConfig } from '../storage'; -import { theme } from '../ui/theme'; -import { TitleBar, Win95Button, Win95Frame, Win95InsetPanel } from '../ui/Win95'; -import { FileLogger } from '../util/FileLogger'; - -// Headless-ish: triggered by the "Send Lasso to Mac" sidebar button. -// Generates a PNG of the currently-lassoed elements via the SDK, POSTs -// it to /sketch on the Mac, then closes. Mac stashes it in ~/EmbedImage/ -// Sketches with a timestamped filename. - -export function SendLasso({ onClose }: { onClose: () => void }): React.JSX.Element { - const [status, setStatus] = useState('Capturing lasso…'); - const [previewUri, setPreviewUri] = useState(null); - const [done, setDone] = useState(false); - - useEffect(() => { - let cancelled = false; - - const WATCHDOG_MS = 20000; - const watchdog = setTimeout(() => { - if (cancelled) return; - cancelled = true; - setStatus('Timed out. Closing…'); - setTimeout(() => PluginManager.closePluginView().catch(() => {}), 1500); - }, WATCHDOG_MS); - - (async () => { - try { - FileLogger.raw('[embedimage] SendLasso start'); - setStatus('Loading config…'); - const cfg = await loadStreamConfig(); - const url = baseUrl(cfg); - FileLogger.raw('[embedimage] SendLasso url=', url); - if (!url) { - setStatus('No Mac server set. Settings → Mac Capture Server.'); - setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2500); - return; - } - if (cancelled) return; - - // Inkling / guibor approach: don't use generateLassoPreview (its - // sandbox path semantics are flaky). Render the WHOLE PAGE to PNG - // via generateNotePng, fetch the lasso rect separately, and ship - // both to the Mac. The Mac can crop or display as-is. - setStatus('Reading note context…'); - const fpRes: any = await PluginCommAPI.getCurrentFilePath(); - const pgRes: any = await PluginCommAPI.getCurrentPageNum(); - const notePath = fpRes?.result ?? fpRes?.filePath ?? fpRes; - const page = pgRes?.result ?? pgRes?.pageNum ?? pgRes; - FileLogger.raw('[embedimage] SendLasso ctx notePath=', notePath, 'page=', page); - if (typeof notePath !== 'string' || typeof page !== 'number') { - setStatus('Could not get current note/page.'); - setTimeout(() => PluginManager.closePluginView().catch(() => {}), 2500); - return; - } - - let lassoRect: any = null; - try { - const lr: any = await PluginCommAPI.getLassoRect(); - if (lr?.success !== false && lr?.result) lassoRect = lr.result; - FileLogger.raw('[embedimage] SendLasso getLassoRect ->', JSON.stringify(lassoRect)); - } catch (e: any) { - FileLogger.raw('[embedimage] SendLasso getLassoRect threw:', e?.message ?? e); - } - - setStatus('Rendering page to PNG…'); - const pngPath = `/data/user/0/com.ratta.supernote.pluginhost/cache/page_${Date.now()}.png`; - FileLogger.raw('[embedimage] SendLasso generateNotePng ->', pngPath); - const gen: any = await PluginFileAPI.generateNotePng({ - notePath, page, times: 1, pngPath, type: 1, // 1 = white background - }); - FileLogger.raw('[embedimage] SendLasso generateNotePng returned:', JSON.stringify(gen)); - if (!gen || gen.success === false) { - setStatus(`Render failed: ${gen?.error?.message ?? 'unknown'}`); - setTimeout(() => PluginManager.closePluginView().catch(() => {}), 3500); - return; - } - if (cancelled) return; - setPreviewUri('file://' + pngPath); - - setStatus('Uploading to Mac…'); - const name = `manta_${new Date().toISOString().replace(/[:.]/g, '-')}.png`; - const qs = lassoRect - ? `?name=${encodeURIComponent(name)}&cropL=${lassoRect.left}&cropT=${lassoRect.top}&cropR=${lassoRect.right}&cropB=${lassoRect.bottom}` - : `?name=${encodeURIComponent(name)}`; - FileLogger.raw('[embedimage] SendLasso POST', `${url}/sketch${qs}`); - const out = await lanPostFile(`${url}/sketch${qs}`, pngPath, 'image/png', 30000); - FileLogger.raw('[embedimage] SendLasso upload ok, response saved at', out); - if (cancelled) return; - setStatus(`Sent to Mac as ${name}`); - setDone(true); - setTimeout(() => PluginManager.closePluginView().catch(() => {}), 1500); - } catch (e: any) { - FileLogger.raw('[embedimage] SendLasso outer threw:', e?.message ?? e); - if (cancelled) return; - setStatus(`Failed: ${e?.message ?? e}`); - setTimeout(() => PluginManager.closePluginView().catch(() => {}), 3000); - } - })(); - return () => { - cancelled = true; - clearTimeout(watchdog); - }; - }, []); - - return ( - - - - - Sending lasso to Mac… - - {previewUri ? ( - - ) : ( - - )} - - {status} - {done ? ( - PluginManager.closePluginView().catch(() => {})} primary> - OK - - ) : null} - - - - ); -} - -const styles = StyleSheet.create({ - root: { flex: 1, backgroundColor: theme.bg }, - center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 12 }, - dialog: { padding: 12, minWidth: 320, gap: 12 }, - heading: { fontFamily: 'VT323', fontSize: 22, color: theme.text }, - previewBox: { height: 220, alignItems: 'center', justifyContent: 'center' }, - preview: { width: '100%', height: '100%' }, - msg: { fontFamily: 'VT323', fontSize: 16, color: theme.textMuted, textAlign: 'center' }, -}); diff --git a/src/screens/Settings.tsx b/src/screens/Settings.tsx index c28a778..fb39762 100644 --- a/src/screens/Settings.tsx +++ b/src/screens/Settings.tsx @@ -12,7 +12,7 @@ import { baseUrl, loadStreamConfig, saveStreamConfig } from '../storage'; import { lanJson } from '../imageProcessor'; import { theme } from '../ui/theme'; import { StatusBar, TitleBar, Win95Button, Win95InsetPanel } from '../ui/Win95'; -import { DEFAULT_STREAM_CONFIG, StreamConfig } from '../types'; +import { DEFAULT_STREAM_CONFIG, LassoFormat, StreamConfig } from '../types'; function clamp(n: number, lo: number, hi: number): number { return Math.max(lo, Math.min(hi, n)); @@ -29,6 +29,7 @@ export function SettingsScreen({ const [port, setPort] = useState(String(DEFAULT_STREAM_CONFIG.port)); const [intervalSec, setIntervalSec] = useState(String(DEFAULT_STREAM_CONFIG.intervalSec)); const [resolutionMul, setResolutionMul] = useState(String(DEFAULT_STREAM_CONFIG.resolutionMul)); + const [lassoFormat, setLassoFormat] = useState(DEFAULT_STREAM_CONFIG.lassoFormat); const [status, setStatus] = useState('loading…'); const [busy, setBusy] = useState(false); @@ -39,6 +40,7 @@ export function SettingsScreen({ setPort(String(cfg.port)); setIntervalSec(String(cfg.intervalSec)); setResolutionMul(String(cfg.resolutionMul)); + setLassoFormat(cfg.lassoFormat); setStatus(cfg.host ? `loaded: ${baseUrl(cfg)}` : 'no server configured'); })(); }, []); @@ -70,6 +72,7 @@ export function SettingsScreen({ port: portNum, intervalSec: intervalNum, resolutionMul: Math.round(mulNum * 10) / 10, + lassoFormat, }; await saveStreamConfig(cfg); setStatus(`saved: ${baseUrl(cfg)}`); @@ -79,7 +82,7 @@ export function SettingsScreen({ } finally { setBusy(false); } - }, [host, port, intervalSec, resolutionMul, onSaved]); + }, [host, port, intervalSec, resolutionMul, lassoFormat, onSaved]); return ( @@ -142,6 +145,30 @@ export function SettingsScreen({ + + Lasso → Mac format + + setLassoFormat('png')} + > + PNG + + setLassoFormat('jpg')} + > + JPEG + + + + File format the Mac will save your "Send to Mac" exports as. + PNG keeps transparency; JPEG is smaller. + + + Test connection @@ -184,6 +211,7 @@ const styles = StyleSheet.create({ fontSize: 16, color: theme.text, }, row: { flexDirection: 'row', gap: 8, marginTop: 8, alignItems: 'center' }, + formatRow: { flexDirection: 'row', gap: 6, marginTop: 4 }, overlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(192,192,192,0.6)', diff --git a/src/storage.ts b/src/storage.ts index 5735744..1ed1a82 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -10,6 +10,7 @@ export async function loadStreamConfig(): Promise { const raw = await ImageProcessor.getConfigValue(STREAM_CONFIG_KEY); if (!raw) return DEFAULT_STREAM_CONFIG; const parsed = JSON.parse(raw); + const fmt = parsed.lassoFormat === 'jpg' ? 'jpg' : 'png'; return { host: typeof parsed.host === 'string' ? parsed.host : '', port: typeof parsed.port === 'number' ? parsed.port : DEFAULT_STREAM_CONFIG.port, @@ -19,6 +20,7 @@ export async function loadStreamConfig(): Promise { typeof parsed.resolutionMul === 'number' ? Math.max(0.1, Math.min(1.0, parsed.resolutionMul)) : DEFAULT_STREAM_CONFIG.resolutionMul, + lassoFormat: fmt as 'png' | 'jpg', }; } catch { return DEFAULT_STREAM_CONFIG; diff --git a/src/types.ts b/src/types.ts index 0a1cd75..a7b9624 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,7 +9,6 @@ export type Screen = | 'refresh' | 'sourcepicker' | 'dropinbox' - | 'sendlasso' | 'recognizelasso'; export type DitherMode = 'none' | 'fs1' | 'fs4' | 'atkinson'; @@ -39,11 +38,14 @@ export const DEFAULT_ADJUSTMENTS: Adjustments = { export type Preset = Adjustments & { name: string }; +export type LassoFormat = 'png' | 'jpg'; + export type StreamConfig = { host: string; port: number; intervalSec: number; - resolutionMul: number; // 0.1 .. 1.0, server downscales each frame + resolutionMul: number; // 0.1 .. 1.0, server downscales each frame + lassoFormat: LassoFormat; // file format used by Send to Mac (lasso) }; export const DEFAULT_STREAM_CONFIG: StreamConfig = { @@ -51,6 +53,7 @@ export const DEFAULT_STREAM_CONFIG: StreamConfig = { port: 9000, intervalSec: 1.0, resolutionMul: 1.0, + lassoFormat: 'png', }; export type SourceKind = 'screen' | 'window' | 'region'; From 211a88bc5c11d2894fb8978b4a986a19b4518810 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 14:22:13 +0000 Subject: [PATCH 21/28] Port Inkling's StitchEditor + native two-image compositor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inkling's StitchEditor.tsx is brought in with our Win95 chrome. The gesture math (edge handles, perpendicular shared handles, overlap drag, MIN_VISIBLE clamping) is copied verbatim because it's the meat. Native compositor (Kotlin) - composeStitch in ImageProcessorModule. Decodes both source bitmaps, applies the 0..1 fractional crops, lays them out vertically or horizontally with `overlap` source-pixel overlap, draws bottom layer then top layer onto an ARGB_8888 canvas with a white background, saves as PNG to the requested path. - JS wrapper exported as composeStitch(a, b, params, outPath). Editor screen - src/screens/StitchEditor.tsx — full port. Replaces Inkling's RNFS / @react-native-community/image-editor / opaque-native-compose deps with our ImageProcessor.composeStitch + PluginNoteAPI.insertImage. - On Confirm: composes to /data/user/0/.../cache/stitch_.png, insertImage()s it, saves the note, closes the plugin view. Full FileLogger trace at /sdcard/EmbedImage/log.txt. User flow - Browser → Tools → "Stitch two images…" flips into stitch-pick mode. First tap on an image becomes image 1; second tap becomes image 2 and routes to the editor with both. Cancel via the same menu item. - Image dimensions are measured (Image.getSize) before constructing the session — the editor's layout math needs width/height up front. Bump 0.8.1 -> 0.9.0 / versionCode 26 -> 27. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- App.tsx | 19 + PluginConfig.json | 4 +- .../com/embedimage/ImageProcessorModule.kt | 95 +++ src/imageProcessor.ts | 29 + src/screens/Browser.tsx | 61 +- src/screens/StitchEditor.tsx | 559 ++++++++++++++++++ src/types.ts | 3 +- 7 files changed, 763 insertions(+), 7 deletions(-) create mode 100644 src/screens/StitchEditor.tsx diff --git a/App.tsx b/App.tsx index 9263288..833f340 100644 --- a/App.tsx +++ b/App.tsx @@ -9,6 +9,7 @@ import { RecognizeLasso } from './src/screens/RecognizeLasso'; import { RefreshScreen } from './src/screens/Refresh'; import { SettingsScreen } from './src/screens/Settings'; import { SourcePicker } from './src/screens/SourcePicker'; +import { StitchEditor, makeSession, StitchSession } from './src/screens/StitchEditor'; import { baseUrl, loadStreamConfig } from './src/storage'; import { DEFAULT_STREAM_CONFIG, Entry, Screen, StreamConfig } from './src/types'; @@ -22,6 +23,7 @@ export default function App(): React.JSX.Element { const [screen, setScreen] = useState('browser'); const [selectedEntry, setSelectedEntry] = useState(null); const [streamConfig, setStreamConfig] = useState(DEFAULT_STREAM_CONFIG); + const [stitchSession, setStitchSession] = useState(null); useEffect(() => { ImageProcessor?.cleanupCache?.().catch(() => {}); @@ -64,6 +66,19 @@ export default function App(): React.JSX.Element { return ; } + if (screen === 'stitch' && stitchSession) { + return ( + { setStitchSession(null); goBrowser(); }} + onInserted={() => { + setStitchSession(null); + PluginManager.closePluginView().catch(() => {}); + }} + /> + ); + } + if (screen === 'preview' && selectedEntry) { return ; } @@ -106,6 +121,10 @@ export default function App(): React.JSX.Element { onOpenCapture={() => setScreen('capture')} onClose={() => {}} busy={false} + onStitchReady={(session) => { + setStitchSession(session); + setScreen('stitch'); + }} /> ); } diff --git a/PluginConfig.json b/PluginConfig.json index 9a19e23..3e49956 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.8.1", - "versionCode": "26", + "versionName": "0.9.0", + "versionCode": "27", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt b/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt index dd8cab8..3a62024 100644 --- a/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt +++ b/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt @@ -2,6 +2,7 @@ package com.embedimage import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.Canvas import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext @@ -383,6 +384,100 @@ class ImageProcessorModule(reactContext: ReactApplicationContext) : } } + // Inkling-style two-image stitch compositor. Crops are 0..1 fractions + // of each source image; overlap is in source-pixel units (matches the + // editor's coordinate space). topLayerIndex picks which image draws + // on top in the overlap zone. + @ReactMethod + fun composeStitch( + img1Path: String, img2Path: String, + crop1Top: Double, crop1Bottom: Double, crop1Left: Double, crop1Right: Double, + crop2Top: Double, crop2Bottom: Double, crop2Left: Double, crop2Right: Double, + direction: String, + overlap: Int, + topLayerIndex: Int, + outPath: String, + promise: Promise, + ) { + var bm1: Bitmap? = null + var bm2: Bitmap? = null + var c1: Bitmap? = null + var c2: Bitmap? = null + var out: Bitmap? = null + try { + bm1 = BitmapFactory.decodeFile(img1Path) + ?: throw RuntimeException("decode failed: $img1Path") + bm2 = BitmapFactory.decodeFile(img2Path) + ?: throw RuntimeException("decode failed: $img2Path") + + c1 = cropBitmap(bm1, crop1Left, crop1Top, crop1Right, crop1Bottom) + c2 = cropBitmap(bm2, crop2Left, crop2Top, crop2Right, crop2Bottom) + + val isVert = direction.equals("vertical", ignoreCase = true) + val ovl = max(0, overlap) + + val totalW: Int + val totalH: Int + val pos1x: Int; val pos1y: Int + val pos2x: Int; val pos2y: Int + if (isVert) { + totalW = max(c1.width, c2.width) + totalH = c1.height + c2.height - ovl + pos1x = (totalW - c1.width) / 2; pos1y = 0 + pos2x = (totalW - c2.width) / 2; pos2y = c1.height - ovl + } else { + totalW = c1.width + c2.width - ovl + totalH = max(c1.height, c2.height) + pos1x = 0; pos1y = (totalH - c1.height) / 2 + pos2x = c1.width - ovl; pos2y = (totalH - c2.height) / 2 + } + + if (totalW <= 0 || totalH <= 0) { + throw RuntimeException("invalid composite size ${totalW}x${totalH}") + } + out = Bitmap.createBitmap(totalW, totalH, Bitmap.Config.ARGB_8888) + val canvas = Canvas(out) + canvas.drawColor(android.graphics.Color.WHITE) + + // Draw the bottom layer first, then the top layer over it. + val drawOrder = if (topLayerIndex == 0) listOf(1, 0) else listOf(0, 1) + for (idx in drawOrder) { + val bm = if (idx == 0) c1 else c2 + val x = if (idx == 0) pos1x else pos2x + val y = if (idx == 0) pos1y else pos2y + canvas.drawBitmap(bm, x.toFloat(), y.toFloat(), null) + } + + val file = File(outPath) + file.parentFile?.mkdirs() + FileOutputStream(file).use { stream -> + out.compress(Bitmap.CompressFormat.PNG, 100, stream) + } + promise.resolve(outPath) + } catch (e: Throwable) { + promise.reject("E_STITCH", e.message ?: e.toString(), e) + } finally { + bm1?.recycle(); bm2?.recycle() + if (c1 != null && c1 !== bm1) c1.recycle() + if (c2 != null && c2 !== bm2) c2.recycle() + out?.recycle() + } + } + + private fun cropBitmap( + src: Bitmap, + left: Double, top: Double, right: Double, bottom: Double, + ): Bitmap { + val l = (left.coerceIn(0.0, 1.0) * src.width).toInt() + val t = (top.coerceIn(0.0, 1.0) * src.height).toInt() + val r = (right.coerceIn(0.0, 1.0) * src.width).toInt() + val b = (bottom.coerceIn(0.0, 1.0) * src.height).toInt() + val w = (src.width - l - r).coerceAtLeast(1) + val h = (src.height - t - b).coerceAtLeast(1) + if (l == 0 && t == 0 && w == src.width && h == src.height) return src + return Bitmap.createBitmap(src, l, t, w, h) + } + @ReactMethod fun setConfigValue(key: String, value: String?, promise: Promise) { try { diff --git a/src/imageProcessor.ts b/src/imageProcessor.ts index 829bd93..e9ebb99 100644 --- a/src/imageProcessor.ts +++ b/src/imageProcessor.ts @@ -34,6 +34,15 @@ type ImageProcessorNative = { timeoutMs: number, ) => Promise; appendFile: (path: string, text: string) => Promise; + composeStitch: ( + img1Path: string, img2Path: string, + crop1Top: number, crop1Bottom: number, crop1Left: number, crop1Right: number, + crop2Top: number, crop2Bottom: number, crop2Left: number, crop2Right: number, + direction: 'vertical' | 'horizontal', + overlap: number, + topLayerIndex: number, + outPath: string, + ) => Promise; cleanupCache: () => Promise; getConfigValue: (key: string) => Promise; setConfigValue: (key: string, value: string | null) => Promise; @@ -119,3 +128,23 @@ export async function lanPostFile( if (!native) throw new Error('ImageProcessor native module missing'); return native.nativeHttpPostFile(url, inputPath, contentType, timeoutMs); } + +export type ImageCrop = { cropTop: number; cropBottom: number; cropLeft: number; cropRight: number }; +export type StitchImage = { path: string; width: number; height: number; crop: ImageCrop }; +export type StitchParams = { direction: 'vertical' | 'horizontal'; overlap: number; topLayerIndex: number }; + +// Composes two cropped images into one PNG. Returns the output path. +export async function composeStitch( + a: StitchImage, b: StitchImage, params: StitchParams, outPath: string, +): Promise { + if (!native) throw new Error('ImageProcessor native module missing'); + return native.composeStitch( + a.path, b.path, + a.crop.cropTop, a.crop.cropBottom, a.crop.cropLeft, a.crop.cropRight, + b.crop.cropTop, b.crop.cropBottom, b.crop.cropLeft, b.crop.cropRight, + params.direction, + Math.max(0, Math.round(params.overlap)), + params.topLayerIndex === 0 ? 0 : 1, + outPath, + ); +} diff --git a/src/screens/Browser.tsx b/src/screens/Browser.tsx index 9f7dfb7..6c8496b 100644 --- a/src/screens/Browser.tsx +++ b/src/screens/Browser.tsx @@ -17,6 +17,8 @@ import { MenuBar, StatusBar, TitleBar, Win95Button, Win95Frame, Win95InsetPanel import { useConnStatus } from '../useConnStatus'; import type { Entry, EntryKind, SortKey, StreamConfig } from '../types'; import { DEFAULT_STREAM_CONFIG } from '../types'; +import { makeSession, StitchSession } from './StitchEditor'; +import type { StitchImage } from '../imageProcessor'; const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.bmp', '.gif', '.webp']; const INTERNAL_ROOT = '/storage/emulated/0'; @@ -77,12 +79,14 @@ export function BrowserScreen({ onOpenCapture, onClose, busy, + onStitchReady, }: { onPickFile: (entry: Entry) => void; onOpenSettings: () => void; onOpenCapture: () => void; onClose: () => void; busy: boolean; + onStitchReady?: (session: StitchSession) => void; }): React.JSX.Element { const [currentDir, setCurrentDir] = useState(DEFAULT_DIR); const [entries, setEntries] = useState([]); @@ -165,15 +169,49 @@ export function BrowserScreen({ const sorted = useMemo(() => sortEntries(entries, sort), [entries, sort]); + // Stitch-pick state: when the user opens Tools → Stitch we flip into + // pick mode. First tap on an image becomes image 1; second tap becomes + // image 2 and immediately opens the StitchEditor. + const [stitchPick, setStitchPick] = useState<{ first: StitchImage | null } | null>(null); + + const measureImage = useCallback((path: string): Promise<{ width: number; height: number }> => { + return new Promise((resolve, reject) => { + Image.getSize('file://' + path, (w, h) => resolve({ width: w, height: h }), (e) => reject(e)); + }); + }, []); + const onPickEntry = useCallback( - (entry: Entry) => { + async (entry: Entry) => { if (entry.kind === 'folder') { setCurrentDir(entry.path); - } else { - onPickFile(entry); + return; + } + if (stitchPick && onStitchReady) { + // Resolve dimensions before constructing the session — the + // editor needs them up front to lay out the preview. + try { + const dim = await measureImage(entry.path); + const img: StitchImage = { + path: entry.path, width: dim.width, height: dim.height, + crop: { cropTop: 0, cropBottom: 0, cropLeft: 0, cropRight: 0 }, + }; + if (!stitchPick.first) { + setStitchPick({ first: img }); + setStatus(`Stitch · picked "${entry.name}" — tap a second image`); + } else { + const session = makeSession(stitchPick.first, img); + setStitchPick(null); + setStatus('Opening stitch editor…'); + onStitchReady(session); + } + } catch (e: any) { + setStatus(`stitch pick failed: ${e?.message ?? e}`); + } + return; } + onPickFile(entry); }, - [onPickFile], + [onPickFile, stitchPick, onStitchReady, measureImage], ); const onSystemPicker = useCallback(async () => { @@ -245,6 +283,21 @@ export function BrowserScreen({ label: 'Tools', items: [ { label: 'Live Capture…', onPress: onOpenCapture }, + { separator: true, label: '' }, + { + label: stitchPick ? 'Cancel stitch pick' : 'Stitch two images…', + onPress: () => { + if (stitchPick) { + setStitchPick(null); + setStatus('Stitch pick cancelled.'); + } else if (onStitchReady) { + setStitchPick({ first: null }); + setStatus('Stitch · tap an image to pick the first'); + } + }, + disabled: !onStitchReady, + }, + { separator: true, label: '' }, { label: 'Settings…', onPress: onOpenSettings }, ], }, diff --git a/src/screens/StitchEditor.tsx b/src/screens/StitchEditor.tsx new file mode 100644 index 0000000..a235920 --- /dev/null +++ b/src/screens/StitchEditor.tsx @@ -0,0 +1,559 @@ +/* eslint-disable react-native/no-inline-styles */ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ActivityIndicator, + Animated, + Dimensions, + GestureResponderEvent, + Image, + PanResponder, + PanResponderGestureState, + Pressable, + SafeAreaView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { PluginNoteAPI } from 'sn-plugin-lib'; +import { composeStitch, ImageCrop, StitchImage, StitchParams } from '../imageProcessor'; +import { theme } from '../ui/theme'; +import { TitleBar, Win95Button, Win95InsetPanel } from '../ui/Win95'; +import { FileLogger } from '../util/FileLogger'; + +// Ported from Laumss/Inkling's StitchEditor. Gesture math is verbatim +// (it's the meat); chrome is replaced with Win95 styling and the actual +// compositing runs through our ImageProcessor.composeStitch native call +// instead of the Inkling-private path. + +const HEADER_H = 32; // our TitleBar is tighter than Inkling's 56 +const CTRL_H = 110; +const PAD = 12; +const HANDLE_LEN = 40; +const HANDLE_THICK = 6; +const HIT_RADIUS = 40; +const MIN_VISIBLE = 0.05; + +type DragEdge = { + kind: 'edge'; + imageIndex: number; + cropKey: keyof ImageCrop; + oppositeKey: keyof ImageCrop; + startVal: number; + sign: number; + pxPerUnit: number; +}; +type DragBoth = { + kind: 'both'; + cropKey: keyof ImageCrop; + oppositeKey: keyof ImageCrop; + startVal0: number; + startVal1: number; + sign: number; + pxPerUnit0: number; + pxPerUnit1: number; +}; +type DragState = DragEdge | DragBoth; + +type Layout = { + scale: number; + dispW: number; + dispH: number; + originX: number; + originY: number; + rects: Array<{ x: number; y: number; w: number; h: number }>; +}; + +const DEFAULT_PARAMS: StitchParams = { direction: 'vertical', overlap: 100, topLayerIndex: 1 }; +const DEFAULT_CROP: ImageCrop = { cropTop: 0, cropBottom: 0, cropLeft: 0, cropRight: 0 }; + +export type StitchSession = { + images: [StitchImage, StitchImage]; + params: StitchParams; +}; + +export function makeSession(a: StitchImage, b: StitchImage, override: Partial = {}): StitchSession { + return { + images: [ + { ...a, crop: { ...DEFAULT_CROP, ...(a.crop ?? {}) } }, + { ...b, crop: { ...DEFAULT_CROP, ...(b.crop ?? {}) } }, + ], + params: { ...DEFAULT_PARAMS, ...override }, + }; +} + +export function StitchEditor({ + session: initial, + onCancel, + onInserted, +}: { + session: StitchSession; + onCancel: () => void; + onInserted: () => void; +}): React.JSX.Element { + const screen = Dimensions.get('window'); + const previewH = screen.height - HEADER_H - CTRL_H - 60; // 60 = our action row + + const [images, setImages] = useState(() => + initial.images.map((img) => ({ ...img, crop: { ...img.crop } })), + ); + const [params, setParams] = useState(() => ({ ...initial.params })); + const [busy, setBusy] = useState(false); + + const imagesRef = useRef(images); + const paramsRef = useRef(params); + useEffect(() => { imagesRef.current = images; }, [images]); + useEffect(() => { paramsRef.current = params; }, [params]); + + const layout: Layout | null = useMemo(() => { + if (images.length < 2) return null; + const dir = params.direction; + const eff = images.map((img) => ({ + w: img.width * (1 - img.crop.cropLeft - img.crop.cropRight), + h: img.height * (1 - img.crop.cropTop - img.crop.cropBottom), + })); + let totalW: number, totalH: number; + if (dir === 'vertical') { + totalW = Math.max(eff[0].w, eff[1].w); + totalH = eff[0].h + eff[1].h - params.overlap; + } else { + totalW = eff[0].w + eff[1].w - params.overlap; + totalH = Math.max(eff[0].h, eff[1].h); + } + const availW = screen.width - PAD * 2; + const availH = previewH - PAD * 2; + const scale = Math.min(availW / Math.max(totalW, 1), availH / Math.max(totalH, 1), 1); + const dispW = totalW * scale; + const dispH = totalH * scale; + const originX = (screen.width - dispW) / 2; + const originY = HEADER_H + (previewH - dispH) / 2; + const rects = [ + { x: 0, y: 0, w: eff[0].w * scale, h: eff[0].h * scale }, + { x: 0, y: 0, w: eff[1].w * scale, h: eff[1].h * scale }, + ]; + if (dir === 'vertical') { + rects[0].x = originX; rects[0].y = originY; + rects[1].x = originX; rects[1].y = originY + eff[0].h * scale - params.overlap * scale; + } else { + rects[0].x = originX; rects[0].y = originY; + rects[1].x = originX + eff[0].w * scale - params.overlap * scale; rects[1].y = originY; + } + return { scale, dispW, dispH, originX, originY, rects }; + }, [images, params, screen, previewH]); + + const layoutRef = useRef(layout); + useEffect(() => { layoutRef.current = layout; }, [layout]); + + const dragRef = useRef(null); + const overlapStartRef = useRef(0); + const dragKindRef = useRef<'edge' | 'both' | 'overlap' | null>(null); + + // Gesture handler — ported verbatim from Inkling. + const pan = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => !busy, + onMoveShouldSetPanResponder: () => !busy, + onPanResponderGrant: (evt: GestureResponderEvent) => { + if (busy) return; + const { pageX, pageY } = evt.nativeEvent; + const lo = layoutRef.current; + const imgs = imagesRef.current; + const p = paramsRef.current; + if (!lo || imgs.length < 2) return; + + const isVert = p.direction === 'vertical'; + const r0 = lo.rects[0]; + const r1 = lo.rects[1]; + const juncY = isVert ? (r0.y + r0.h + r1.y) / 2 : (r0.y + r0.h / 2); + const juncX = isVert ? (r0.x + r0.w / 2) : (r0.x + r0.w + r1.x) / 2; + + type H = { x: number; y: number; type: 'outerAxis' | 'overlap' | 'perpShared'; + imgIdx?: number; hitEdge?: string; edge?: string }; + const allHandles: H[] = isVert ? [ + { x: r0.x + r0.w / 2, y: r0.y, type: 'outerAxis', imgIdx: 0, hitEdge: 'top' }, + { x: r1.x + r1.w / 2, y: r1.y + r1.h, type: 'outerAxis', imgIdx: 1, hitEdge: 'bottom' }, + { x: juncX, y: juncY, type: 'overlap' }, + { x: lo.originX, y: juncY, type: 'perpShared', edge: 'left' }, + { x: lo.originX + lo.dispW, y: juncY, type: 'perpShared', edge: 'right' }, + ] : [ + { x: r0.x, y: r0.y + r0.h / 2, type: 'outerAxis', imgIdx: 0, hitEdge: 'left' }, + { x: r1.x + r1.w, y: r1.y + r1.h / 2, type: 'outerAxis', imgIdx: 1, hitEdge: 'right' }, + { x: juncX, y: juncY, type: 'overlap' }, + { x: juncX, y: lo.originY, type: 'perpShared', edge: 'top' }, + { x: juncX, y: lo.originY + lo.dispH, type: 'perpShared', edge: 'bottom' }, + ]; + + let bestDist = HIT_RADIUS; + let bestH: H | null = null; + for (const h of allHandles) { + const d = Math.hypot(pageX - h.x, pageY - h.y); + if (d < bestDist) { bestDist = d; bestH = h; } + } + + if (!bestH) { + overlapStartRef.current = p.overlap; + dragKindRef.current = 'overlap'; + dragRef.current = null; + return; + } + + if (bestH.type === 'overlap') { + overlapStartRef.current = p.overlap; + dragKindRef.current = 'overlap'; + dragRef.current = null; + } else if (bestH.type === 'outerAxis') { + const idx = bestH.imgIdx!; + const hitEdge = bestH.hitEdge! as 'top' | 'bottom' | 'left' | 'right'; + const targetEdge = + hitEdge === 'top' ? 'bottom' : + hitEdge === 'bottom' ? 'top' : + hitEdge === 'left' ? 'right' : 'left'; + const cropKey = `crop${cap(targetEdge)}` as keyof ImageCrop; + const oppositeKey = `crop${cap(hitEdge)}` as keyof ImageCrop; + const isVertAxis = hitEdge === 'top' || hitEdge === 'bottom'; + const sign = (hitEdge === 'top' || hitEdge === 'left') ? 1 : -1; + const imgPixelSize = isVertAxis ? imgs[idx].height : imgs[idx].width; + dragRef.current = { + kind: 'edge', + imageIndex: idx, + cropKey, oppositeKey, + startVal: imgs[idx].crop[cropKey], + sign, + pxPerUnit: imgPixelSize * lo.scale, + }; + dragKindRef.current = 'edge'; + } else { + const edge = bestH.edge! as 'top' | 'bottom' | 'left' | 'right'; + const cropKey = `crop${cap(edge)}` as keyof ImageCrop; + const oppositeEdge = + edge === 'top' ? 'bottom' : + edge === 'bottom' ? 'top' : + edge === 'left' ? 'right' : 'left'; + const oppositeKey = `crop${cap(oppositeEdge)}` as keyof ImageCrop; + const isVertAxis = edge === 'top' || edge === 'bottom'; + const sign = (edge === 'top' || edge === 'left') ? 1 : -1; + dragRef.current = { + kind: 'both', + cropKey, oppositeKey, + startVal0: imgs[0].crop[cropKey], + startVal1: imgs[1].crop[cropKey], + sign, + pxPerUnit0: (isVertAxis ? imgs[0].height : imgs[0].width) * lo.scale, + pxPerUnit1: (isVertAxis ? imgs[1].height : imgs[1].width) * lo.scale, + }; + dragKindRef.current = 'both'; + } + }, + + onPanResponderMove: (_: GestureResponderEvent, g: PanResponderGestureState) => { + if (busy) return; + if (dragKindRef.current === 'edge' && dragRef.current?.kind === 'edge') { + const d = dragRef.current; + const img = imagesRef.current[d.imageIndex]; + if (!img) return; + const isVertAxis = d.cropKey === 'cropTop' || d.cropKey === 'cropBottom'; + const screenDelta = isVertAxis ? g.dy : g.dx; + const cropDelta = (d.sign * screenDelta) / d.pxPerUnit; + const maxVal = 1 - MIN_VISIBLE - img.crop[d.oppositeKey]; + const newVal = Math.max(0, Math.min(maxVal, d.startVal + cropDelta)); + setImages((prev) => { + const next = [...prev]; + next[d.imageIndex] = { + ...next[d.imageIndex], + crop: { ...next[d.imageIndex].crop, [d.cropKey]: newVal }, + }; + return next; + }); + } else if (dragKindRef.current === 'both' && dragRef.current?.kind === 'both') { + const d = dragRef.current; + const imgs = imagesRef.current; + const isVertAxis = d.cropKey === 'cropTop' || d.cropKey === 'cropBottom'; + const screenDelta = isVertAxis ? g.dy : g.dx; + const delta0 = (d.sign * screenDelta) / d.pxPerUnit0; + const delta1 = (d.sign * screenDelta) / d.pxPerUnit1; + const max0 = 1 - MIN_VISIBLE - imgs[0].crop[d.oppositeKey]; + const max1 = 1 - MIN_VISIBLE - imgs[1].crop[d.oppositeKey]; + const v0 = Math.max(0, Math.min(max0, d.startVal0 + delta0)); + const v1 = Math.max(0, Math.min(max1, d.startVal1 + delta1)); + setImages((prev) => { + const next = [...prev]; + next[0] = { ...next[0], crop: { ...next[0].crop, [d.cropKey]: v0 } }; + next[1] = { ...next[1], crop: { ...next[1].crop, [d.cropKey]: v1 } }; + return next; + }); + } else if (dragKindRef.current === 'overlap') { + const lo = layoutRef.current; + const p = paramsRef.current; + const imgs = imagesRef.current; + if (!lo || imgs.length < 2) return; + const isVert = p.direction === 'vertical'; + const screenDelta = isVert ? -g.dy : -g.dx; + const imgDelta = screenDelta / lo.scale; + const dim0 = isVert + ? imgs[0].height * (1 - imgs[0].crop.cropTop - imgs[0].crop.cropBottom) + : imgs[0].width * (1 - imgs[0].crop.cropLeft - imgs[0].crop.cropRight); + const dim1 = isVert + ? imgs[1].height * (1 - imgs[1].crop.cropTop - imgs[1].crop.cropBottom) + : imgs[1].width * (1 - imgs[1].crop.cropLeft - imgs[1].crop.cropRight); + const maxOvl = Math.min(dim0, dim1) * 0.8; + const newOvl = Math.max(0, Math.min(maxOvl, overlapStartRef.current + imgDelta)); + setParams((prev) => ({ ...prev, overlap: Math.round(newOvl) })); + } + }, + onPanResponderRelease: () => { dragRef.current = null; dragKindRef.current = null; }, + onPanResponderTerminate: () => { dragRef.current = null; dragKindRef.current = null; }, + }), + ).current; + + const toggleDirection = useCallback(() => { + setParams((p) => ({ + ...p, + direction: p.direction === 'vertical' ? 'horizontal' : 'vertical', + overlap: Math.round(p.overlap * 0.5), + })); + }, []); + const swapOrder = useCallback(() => setImages((prev) => [prev[1], prev[0]]), []); + const toggleTopLayer = useCallback(() => { + setParams((p) => ({ ...p, topLayerIndex: p.topLayerIndex === 0 ? 1 : 0 })); + }, []); + const adjustOverlap = useCallback((delta: number) => { + setParams((p) => { + const imgs = imagesRef.current; + if (imgs.length < 2) return p; + const dim0 = p.direction === 'vertical' + ? imgs[0].height * (1 - imgs[0].crop.cropTop - imgs[0].crop.cropBottom) + : imgs[0].width * (1 - imgs[0].crop.cropLeft - imgs[0].crop.cropRight); + const dim1 = p.direction === 'vertical' + ? imgs[1].height * (1 - imgs[1].crop.cropTop - imgs[1].crop.cropBottom) + : imgs[1].width * (1 - imgs[1].crop.cropLeft - imgs[1].crop.cropRight); + const maxOvl = Math.min(dim0, dim1) * 0.8; + return { ...p, overlap: Math.max(0, Math.min(Math.round(maxOvl), p.overlap + delta)) }; + }); + }, []); + + const confirmCompose = useCallback(async () => { + if (busy) return; + setBusy(true); + try { + const outPath = + `/data/user/0/com.ratta.supernote.pluginhost/cache/stitch_${Date.now()}.png`; + FileLogger.raw('[embedimage] stitch compose start', { + a: images[0].path, b: images[1].path, params, + }); + await composeStitch(images[0], images[1], params, outPath); + FileLogger.raw('[embedimage] stitch compose ok ->', outPath); + const res: any = await PluginNoteAPI.insertImage(outPath); + FileLogger.raw('[embedimage] stitch insertImage ->', JSON.stringify(res)); + if (!res || res.success === false) { + throw new Error(res?.error?.message ?? 'insertImage failed'); + } + try { await PluginNoteAPI.saveCurrentNote(); } catch {} + onInserted(); + } catch (e: any) { + FileLogger.raw('[embedimage] stitch failed:', e?.message ?? e); + setBusy(false); + } + }, [busy, images, params, onInserted]); + + if (!layout) { + return ( + + + + Need two images to stitch. + + + ); + } + + const isVert = params.direction === 'vertical'; + const drawOrder = params.topLayerIndex === 0 ? [1, 0] : [0, 1]; + + const handles = computeHandles(layout, isVert); + + return ( + + + + + + + {drawOrder.map((idx) => { + const r = layout.rects[idx]; + const img = images[idx]; + const fullW = img.width * layout.scale; + const fullH = img.height * layout.scale; + const offsetL = img.crop.cropLeft * img.width * layout.scale; + const offsetT = img.crop.cropTop * img.height * layout.scale; + return ( + + + + ); + })} + + {params.overlap > 0 && ( + + )} + + {[0, 1].map((idx) => { + const r = layout.rects[idx]; + return ( + + + + {idx + 1} + + + ); + })} + + {handles.map((h, i) => ( + + ))} + + + + + + {isVert ? '↕ Vertical' : '↔ Horizontal'} + + ⇅ Swap + + ☰ Top: {params.topLayerIndex + 1} + + + + Overlap: {params.overlap}px + adjustOverlap(-50)} disabled={busy}>−50 + adjustOverlap(-10)} disabled={busy}>−10 + adjustOverlap(10)} disabled={busy}>+10 + adjustOverlap(50)} disabled={busy}>+50 + + + + + Cancel + + Stitch & Insert + + + {busy && ( + + + Compositing… + + )} + + ); +} + +function cap(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } + +function computeHandles(layout: Layout, isVert: boolean) { + const r0 = layout.rects[0]; + const r1 = layout.rects[1]; + const juncY = isVert ? (r0.y + r0.h + r1.y) / 2 : (r0.y + r0.h / 2); + const juncX = isVert ? (r0.x + r0.w / 2) : (r0.x + r0.w + r1.x) / 2; + if (isVert) { + return [ + { x: r0.x + r0.w / 2, y: r0.y, horiz: true, type: 'outerAxis' as const }, + { x: r1.x + r1.w / 2, y: r1.y + r1.h, horiz: true, type: 'outerAxis' as const }, + { x: juncX, y: juncY, horiz: true, type: 'overlap' as const }, + { x: layout.originX, y: juncY, horiz: false, type: 'perpShared' as const }, + { x: layout.originX + layout.dispW, y: juncY, horiz: false, type: 'perpShared' as const }, + ]; + } + return [ + { x: r0.x, y: r0.y + r0.h / 2, horiz: false, type: 'outerAxis' as const }, + { x: r1.x + r1.w, y: r1.y + r1.h / 2, horiz: false, type: 'outerAxis' as const }, + { x: juncX, y: juncY, horiz: false, type: 'overlap' as const }, + { x: juncX, y: layout.originY, horiz: true, type: 'perpShared' as const }, + { x: juncX, y: layout.originY + layout.dispH, horiz: true, type: 'perpShared' as const }, + ]; +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: theme.bg }, + empty: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + emptyTxt: { fontFamily: 'VT323', fontSize: 18, color: theme.text }, + preview: { position: 'relative', backgroundColor: theme.bg }, + compositeBorder: { position: 'absolute', borderWidth: 1, borderColor: theme.shadow }, + imgBorder: { position: 'absolute', borderWidth: 1, borderStyle: 'dashed' }, + imgLabel: { position: 'absolute', backgroundColor: theme.titleBg, paddingHorizontal: 6, paddingVertical: 2 }, + imgLabelTxt: { color: theme.titleFg, fontSize: 13, fontFamily: 'VT323' }, + overlapZone: { + position: 'absolute', + backgroundColor: 'rgba(0,0,0,0.12)', + borderWidth: 1, borderColor: 'rgba(0,0,0,0.25)', borderStyle: 'dotted', + }, + handleBar: { position: 'absolute', backgroundColor: theme.text, borderRadius: 3 }, + handleH: { width: HANDLE_LEN, height: HANDLE_THICK }, + handleV: { width: HANDLE_THICK, height: HANDLE_LEN }, + handleOverlap: { backgroundColor: theme.shadow }, + controlPanel: { padding: 6, gap: 6 }, + controlRow: { flexDirection: 'row', alignItems: 'center', gap: 6, flexWrap: 'wrap' }, + overlapLabel: { fontFamily: 'VT323', fontSize: 15, color: theme.text, minWidth: 110 }, + actionRow: { + flexDirection: 'row', gap: 6, + paddingHorizontal: 6, paddingVertical: 6, + alignItems: 'center', backgroundColor: theme.bg, + }, + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(192,192,192,0.7)', + alignItems: 'center', justifyContent: 'center', gap: 12, + }, + busyTxt: { fontFamily: 'VT323', fontSize: 16, color: theme.text }, +}); diff --git a/src/types.ts b/src/types.ts index a7b9624..41b59da 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,7 +9,8 @@ export type Screen = | 'refresh' | 'sourcepicker' | 'dropinbox' - | 'recognizelasso'; + | 'recognizelasso' + | 'stitch'; export type DitherMode = 'none' | 'fs1' | 'fs4' | 'atkinson'; From 6a0c8068ce61b6e565aca2f1bc30a2cd9d967bf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 14:34:54 +0000 Subject: [PATCH 22/28] =?UTF-8?q?Lasso=20=E2=86=92=20pick=20note=20layers?= =?UTF-8?q?=20=E2=86=92=20composite=20as=20image=20(Inkling-style,=20on=20?= =?UTF-8?q?canvas)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New lasso-toolbar button "Stitch Layers" (id=6) that opens a layer picker on the canvas. Same convertElement2Sticker pipeline Inkling uses for clipSave, but applied per-user-selection of which Supernote note layers (0..3) to include. Flow - surveyLasso(): PluginCommAPI.getLassoRect + .getLassoElements PluginFileAPI.getLayers group elements by layerNum -> show count per layer. - composeLassoLayers({selectedLayerIds, transparentBg}): filter lasso elements to selected layers PluginCommAPI.convertElement2Sticker(...) PluginCommAPI.getStickerSize / .generateStickerThumbnail if !transparent, ImageProcessor.flattenOntoBg over white - insertComposed(): PluginNoteAPI.insertImage (known reliable). UI (LayerStitch screen, Win95 chrome) - Per-layer rows with ☐/☑ checkboxes + element counts, "(current)" badge on the active layer, muted style for layers with 0 lassoed elements. - Transparent / White background toggle. - Three actions: Preview (re-renders into the dialog), Insert (insertImage into current note), To Mac (re-uses /sketch endpoint with the chosen lassoFormat). - StatusBar shows running state + total picked elements. - Errors path: empty lasso → friendly dialog with OK button. Native flattenOntoBg - ImageProcessorModule.flattenOntoBg(input, out, r,g,b). Loads the PNG, draws a solid fill, then drawBitmap on top. Used by the composer when the user picks the White option. Bump 0.9.0 -> 0.9.1 / versionCode 27 -> 28. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- App.tsx | 7 + PluginConfig.json | 4 +- .../com/embedimage/ImageProcessorModule.kt | 34 +++ index.js | 13 +- src/imageProcessor.ts | 11 + src/layerStitch.ts | 150 ++++++++++ src/screens/LayerStitch.tsx | 260 ++++++++++++++++++ src/types.ts | 3 +- 8 files changed, 477 insertions(+), 5 deletions(-) create mode 100644 src/layerStitch.ts create mode 100644 src/screens/LayerStitch.tsx diff --git a/App.tsx b/App.tsx index 833f340..24271ff 100644 --- a/App.tsx +++ b/App.tsx @@ -5,6 +5,7 @@ import { BrowserScreen } from './src/screens/Browser'; import { CaptureScreen } from './src/screens/Capture'; import { DropInbox } from './src/screens/DropInbox'; import { PreviewScreen } from './src/screens/Preview'; +import { LayerStitch } from './src/screens/LayerStitch'; import { RecognizeLasso } from './src/screens/RecognizeLasso'; import { RefreshScreen } from './src/screens/Refresh'; import { SettingsScreen } from './src/screens/Settings'; @@ -18,6 +19,7 @@ import { DEFAULT_STREAM_CONFIG, Entry, Screen, StreamConfig } from './src/types' const BUTTON_REFRESH = 2; const BUTTON_DROP = 3; const BUTTON_LASSO_RECOGNIZE = 5; +const BUTTON_LASSO_STITCH_LAYERS = 6; export default function App(): React.JSX.Element { const [screen, setScreen] = useState('browser'); @@ -37,6 +39,7 @@ export default function App(): React.JSX.Element { if (msg?.id === BUTTON_REFRESH) setScreen('refresh'); else if (msg?.id === BUTTON_DROP) setScreen('dropinbox'); else if (msg?.id === BUTTON_LASSO_RECOGNIZE) setScreen('recognizelasso'); + else if (msg?.id === BUTTON_LASSO_STITCH_LAYERS) setScreen('layerstitch'); }, }); return () => { @@ -66,6 +69,10 @@ export default function App(): React.JSX.Element { return ; } + if (screen === 'layerstitch') { + return ; + } + if (screen === 'stitch' && stitchSession) { return ( + out.compress(Bitmap.CompressFormat.PNG, 100, stream) + } + promise.resolve(outPath) + } catch (e: Throwable) { + promise.reject("E_FLATTEN", e.message ?: e.toString(), e) + } finally { + src?.recycle() + out?.recycle() + } + } + @ReactMethod fun setConfigValue(key: String, value: String?, promise: Promise) { try { diff --git a/index.js b/index.js index b7b62cc..e159c64 100644 --- a/index.js +++ b/index.js @@ -14,8 +14,9 @@ PluginManager.init(); export const BUTTON_MAIN = 1; export const BUTTON_REFRESH = 2; export const BUTTON_DROP = 3; -export const BUTTON_LASSO_SEND = 4; // lasso toolbar — ship to Mac -export const BUTTON_LASSO_RECOGNIZE = 5; // lasso toolbar — OCR -> insert text +export const BUTTON_LASSO_SEND = 4; // lasso toolbar — ship to Mac +export const BUTTON_LASSO_RECOGNIZE = 5; // lasso toolbar — OCR -> insert text +export const BUTTON_LASSO_STITCH_LAYERS = 6; // lasso toolbar — layer picker → image const ICON = Image.resolveAssetSource(require('./assets/icon.png')).uri; @@ -78,6 +79,14 @@ PluginManager.registerButton(2, ['NOTE'], { editDataTypes: [0, 1, 3], }).catch((e) => console.log('[embedimage] recognize button register skipped:', e)); +// Lasso → choose which note layers to include → composite as PNG. +// Opens a layer picker (showType:1) so the user can toggle which layers +// participate and pick transparent / white background. +PluginManager.registerButton(2, ['NOTE'], { + ...baseBtn(BUTTON_LASSO_STITCH_LAYERS, 'Stitch Layers'), + editDataTypes: [0, 1, 2, 3, 4, 5], +}).catch((e) => console.log('[embedimage] stitch-layers button register skipped:', e)); + PluginManager.registerButtonListener({ onButtonPress: (msg) => { // showType:0 buttons don't open a view, so we handle their action diff --git a/src/imageProcessor.ts b/src/imageProcessor.ts index e9ebb99..0f4e94c 100644 --- a/src/imageProcessor.ts +++ b/src/imageProcessor.ts @@ -43,6 +43,9 @@ type ImageProcessorNative = { topLayerIndex: number, outPath: string, ) => Promise; + flattenOntoBg: ( + inputPath: string, outPath: string, r: number, g: number, b: number, + ) => Promise; cleanupCache: () => Promise; getConfigValue: (key: string) => Promise; setConfigValue: (key: string, value: string | null) => Promise; @@ -133,6 +136,14 @@ export type ImageCrop = { cropTop: number; cropBottom: number; cropLeft: number; export type StitchImage = { path: string; width: number; height: number; crop: ImageCrop }; export type StitchParams = { direction: 'vertical' | 'horizontal'; overlap: number; topLayerIndex: number }; +// Flatten a (potentially transparent) PNG over a solid background color. +export async function flattenOntoBg( + inputPath: string, outPath: string, rgb: { r: number; g: number; b: number }, +): Promise { + if (!native) throw new Error('ImageProcessor native module missing'); + return native.flattenOntoBg(inputPath, outPath, rgb.r, rgb.g, rgb.b); +} + // Composes two cropped images into one PNG. Returns the output path. export async function composeStitch( a: StitchImage, b: StitchImage, params: StitchParams, outPath: string, diff --git a/src/layerStitch.ts b/src/layerStitch.ts new file mode 100644 index 0000000..ec34ef0 --- /dev/null +++ b/src/layerStitch.ts @@ -0,0 +1,150 @@ +import { PluginCommAPI, PluginFileAPI, PluginManager } from 'sn-plugin-lib'; +import { flattenOntoBg } from './imageProcessor'; +import { FileLogger } from './util/FileLogger'; + +// Inkling/jpmoo-inspired: take the current lasso selection, optionally +// filter to a subset of note layers, render to a sticker via +// convertElement2Sticker, then rasterize that to a PNG via +// generateStickerThumbnail. Optionally flatten the (transparent) +// rasterization onto a solid background. + +export type LayerInfo = { + layerId: number; // 0..3 user layers + name: string; + isCurrent: boolean; + isVisible: boolean; + count: number; // # of lassoed elements on this layer +}; + +export type LassoSurvey = { + notePath: string; + page: number; + rect: { left: number; top: number; right: number; bottom: number }; + layers: LayerInfo[]; + elementsByLayer: Map; +}; + +export async function surveyLasso(): Promise { + const fpRes: any = await PluginCommAPI.getCurrentFilePath(); + const pgRes: any = await PluginCommAPI.getCurrentPageNum(); + const notePath = fpRes?.result ?? fpRes?.filePath ?? fpRes; + const page = pgRes?.result ?? pgRes?.pageNum ?? pgRes; + if (typeof notePath !== 'string' || typeof page !== 'number') return null; + + const rectRes: any = await PluginCommAPI.getLassoRect(); + if (!rectRes?.success || !rectRes.result) return null; + const rect = rectRes.result; + + const elRes: any = await PluginCommAPI.getLassoElements(); + if (!elRes?.success || !Array.isArray(elRes.result)) return null; + const elements: any[] = elRes.result; + + const lyRes: any = await PluginFileAPI.getLayers(notePath, page); + if (!lyRes?.success || !Array.isArray(lyRes.result)) return null; + const rawLayers: any[] = lyRes.result; + + const elementsByLayer = new Map(); + for (const el of elements) { + const ln = (el?.layerNum ?? 0) as number; + if (!elementsByLayer.has(ln)) elementsByLayer.set(ln, []); + elementsByLayer.get(ln)!.push(el); + } + + const layers: LayerInfo[] = rawLayers + .map((l: any) => { + const id = (l.layerId !== undefined ? l.layerId : l.layerNum) as number; + return { + layerId: id, + name: typeof l.name === 'string' && l.name.length > 0 ? l.name : `Layer ${id + 1}`, + isCurrent: !!l.isCurrentLayer, + isVisible: l.isVisible !== false, + count: elementsByLayer.get(id)?.length ?? 0, + }; + }) + .filter((l) => l.layerId >= 0) + .sort((a, b) => a.layerId - b.layerId); + + FileLogger.log('LayerStitch', 'surveyLasso', { + notePath, page, rect, layerCount: layers.length, totalEls: elements.length, + }); + + return { notePath, page, rect, layers, elementsByLayer }; +} + +export type ComposeOpts = { + selectedLayerIds: number[]; + transparentBg: boolean; +}; + +// Produce a PNG that contains only elements from `selectedLayerIds` +// (within the current lasso). Returns the absolute file path. The +// caller is responsible for inserting/sending. +export async function composeLassoLayers( + survey: LassoSurvey, opts: ComposeOpts, +): Promise { + const pluginDir = await PluginManager.getPluginDirPath(); + if (!pluginDir) throw new Error('cannot resolve plugin directory'); + const trimmed = pluginDir.replace(/\/+$/, ''); + const stamp = Date.now(); + const stickerPath = `${trimmed}/layerstitch-${stamp}.sticker`; + const pngPath = `${trimmed}/layerstitch-${stamp}.png`; + + const wanted = new Set(opts.selectedLayerIds); + const picked: any[] = []; + for (const [layerId, els] of survey.elementsByLayer.entries()) { + if (wanted.has(layerId)) picked.push(...els); + } + if (picked.length === 0) throw new Error('No elements selected (pick at least one layer with content)'); + + FileLogger.log('LayerStitch', 'composing', { + pickedCount: picked.length, layers: opts.selectedLayerIds, transparent: opts.transparentBg, + }); + + // Clear stale sticker file from a prior run so we don't overwrite a + // locked handle. + try { PluginCommAPI.clearElementCache(); } catch {} + + const deviceType = await PluginManager.getDeviceType().catch(() => 5); + const convertRes: any = await PluginCommAPI.convertElement2Sticker({ + machineType: deviceType, elements: picked, stickerPath, + }); + FileLogger.log('LayerStitch', 'convertElement2Sticker ->', convertRes); + if (!convertRes?.success) { + throw new Error(convertRes?.error?.message ?? 'convertElement2Sticker failed'); + } + + const sizeRes: any = await PluginCommAPI.getStickerSize(stickerPath); + FileLogger.log('LayerStitch', 'getStickerSize ->', sizeRes); + const size = sizeRes?.result; + if (!size?.width || !size?.height) { + throw new Error(`getStickerSize returned ${JSON.stringify(size)}`); + } + + const thumbRes: any = await PluginCommAPI.generateStickerThumbnail(stickerPath, pngPath, size); + FileLogger.log('LayerStitch', 'generateStickerThumbnail ->', thumbRes); + if (thumbRes?.success === false) { + throw new Error(thumbRes?.error?.message ?? 'generateStickerThumbnail failed'); + } + + if (opts.transparentBg) { + return pngPath; // already has transparent BG (sticker renders so) + } + const flatPath = `${trimmed}/layerstitch-${stamp}-flat.png`; + await flattenOntoBg(pngPath, flatPath, { r: 255, g: 255, b: 255 }); + FileLogger.log('LayerStitch', 'flattened ->', flatPath); + return flatPath; +} + +// Insert the composed PNG back at the lasso location. Uses +// PluginNoteAPI.insertImage (host picks default position) as the +// reliable fallback — we tried positioned insertElements earlier and +// it kept rejecting picture payloads. +export async function insertComposed(pngPath: string): Promise { + const { PluginNoteAPI } = await import('sn-plugin-lib'); + const res: any = await PluginNoteAPI.insertImage(pngPath); + FileLogger.log('LayerStitch', 'insertImage ->', res); + if (!res || res.success === false) { + throw new Error(res?.error?.message ?? 'insertImage failed'); + } + try { await PluginNoteAPI.saveCurrentNote(); } catch {} +} diff --git a/src/screens/LayerStitch.tsx b/src/screens/LayerStitch.tsx new file mode 100644 index 0000000..af2639f --- /dev/null +++ b/src/screens/LayerStitch.tsx @@ -0,0 +1,260 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + ActivityIndicator, Image, Pressable, SafeAreaView, ScrollView, + StyleSheet, Text, View, +} from 'react-native'; +import { PluginManager } from 'sn-plugin-lib'; +import { lanPostFile } from '../imageProcessor'; +import { + composeLassoLayers, insertComposed, LassoSurvey, surveyLasso, +} from '../layerStitch'; +import { baseUrl, loadStreamConfig } from '../storage'; +import { theme } from '../ui/theme'; +import { StatusBar, TitleBar, Win95Button, Win95Frame, Win95InsetPanel } from '../ui/Win95'; +import { FileLogger } from '../util/FileLogger'; + +// "Stitch Layers" — lasso some content on the canvas, tap this button, +// pick which Supernote note layers (0..3) to include, choose transparent +// or white background, then insert/send the composited image. + +export function LayerStitch({ onClose }: { onClose: () => void }): React.JSX.Element { + const [survey, setSurvey] = useState(null); + const [error, setError] = useState(null); + const [selected, setSelected] = useState>(new Set()); + const [transparent, setTransparent] = useState(true); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState('Reading lasso…'); + const [previewUri, setPreviewUri] = useState(null); + + useEffect(() => { + (async () => { + try { + const s = await surveyLasso(); + if (!s) { + setError('No lasso selection found. Lasso some content first.'); + setStatus('Idle'); + return; + } + setSurvey(s); + // Default selection: every layer that has at least one lassoed element. + const def = new Set(s.layers.filter((l) => l.count > 0).map((l) => l.layerId)); + setSelected(def); + setStatus(`Lasso ok · ${s.layers.length} layer${s.layers.length === 1 ? '' : 's'}`); + } catch (e: any) { + FileLogger.log('LayerStitch', 'survey threw', e?.message ?? String(e)); + setError(e?.message ?? String(e)); + } + })(); + }, []); + + const toggle = useCallback((layerId: number) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(layerId)) next.delete(layerId); else next.add(layerId); + return next; + }); + }, []); + + const totalPicked = survey + ? survey.layers.filter((l) => selected.has(l.layerId)).reduce((n, l) => n + l.count, 0) + : 0; + + const onPreview = useCallback(async () => { + if (!survey || busy) return; + setBusy(true); + setStatus('Composing preview…'); + try { + const path = await composeLassoLayers(survey, { + selectedLayerIds: [...selected], + transparentBg: transparent, + }); + setPreviewUri('file://' + path + '?t=' + Date.now()); + setStatus(`Preview ready (${path.split('/').pop()})`); + } catch (e: any) { + FileLogger.log('LayerStitch', 'preview threw', e?.message ?? String(e)); + setStatus(`Preview failed: ${e?.message ?? e}`); + } finally { + setBusy(false); + } + }, [survey, selected, transparent, busy]); + + const onInsert = useCallback(async () => { + if (!survey || busy) return; + setBusy(true); + setStatus('Composing & inserting…'); + try { + const path = await composeLassoLayers(survey, { + selectedLayerIds: [...selected], + transparentBg: transparent, + }); + await insertComposed(path); + setStatus('Inserted. Closing…'); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 800); + } catch (e: any) { + FileLogger.log('LayerStitch', 'insert threw', e?.message ?? String(e)); + setStatus(`Insert failed: ${e?.message ?? e}`); + setBusy(false); + } + }, [survey, selected, transparent, busy]); + + const onSendToMac = useCallback(async () => { + if (!survey || busy) return; + setBusy(true); + setStatus('Composing & sending to Mac…'); + try { + const cfg = await loadStreamConfig(); + const url = baseUrl(cfg); + if (!url) { + setStatus('No Mac server set. Settings → Mac Capture Server.'); + setBusy(false); + return; + } + const path = await composeLassoLayers(survey, { + selectedLayerIds: [...selected], + transparentBg: transparent, + }); + const name = `manta_layers_${new Date().toISOString().replace(/[:.]/g, '-')}.${cfg.lassoFormat}`; + await lanPostFile(`${url}/sketch?name=${encodeURIComponent(name)}&format=${cfg.lassoFormat}`, path, 'image/png', 30000); + setStatus(`Sent to Mac as ${name}. Closing…`); + setTimeout(() => PluginManager.closePluginView().catch(() => {}), 1200); + } catch (e: any) { + FileLogger.log('LayerStitch', 'send threw', e?.message ?? String(e)); + setStatus(`Send failed: ${e?.message ?? e}`); + setBusy(false); + } + }, [survey, selected, transparent, busy]); + + if (error) { + return ( + + + + + No selection. + {error} + OK + + + + ); + } + + return ( + + + + + Pick layers to include + {!survey ? ( + + + {status} + + ) : ( + + {survey.layers.length === 0 ? ( + No layers found on this page. + ) : ( + survey.layers.map((l) => { + const isSelected = selected.has(l.layerId); + const hasContent = l.count > 0; + return ( + toggle(l.layerId)} + disabled={busy} + > + + {isSelected ? '☑' : '☐'} + + + {l.name}{l.isCurrent ? ' (current)' : ''} + + + + {l.count} item{l.count === 1 ? '' : 's'} + + + ); + }) + )} + + )} + + + Background: + setTransparent(true)} disabled={busy} + >Transparent + setTransparent(false)} disabled={busy} + >White + + + {previewUri ? ( + + + + ) : null} + + + + Cancel + + Preview + To Mac + Insert + + + {status} · {totalPicked} item{totalPicked === 1 ? '' : 's'} picked + + {busy && ( + + + + )} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: theme.bg }, + body: { flex: 1 }, + bodyInner: { padding: 8, gap: 8 }, + section: { fontFamily: 'VT323', fontSize: 18, color: theme.text }, + loading: { padding: 12, flexDirection: 'row', alignItems: 'center', gap: 8 }, + dim: { fontFamily: 'VT323', fontSize: 14, color: theme.textMuted }, + layerList: { padding: 4 }, + layerRow: { + flexDirection: 'row', alignItems: 'center', gap: 6, + paddingHorizontal: 8, paddingVertical: 6, + }, + layerRowMuted: { opacity: 0.5 }, + checkbox: { width: 22, height: 22, alignItems: 'center', justifyContent: 'center' }, + checkboxMark: { fontFamily: 'VT323', fontSize: 18, color: theme.text }, + layerName: { fontFamily: 'VT323', fontSize: 16, color: theme.text }, + layerCount: { fontFamily: 'VT323', fontSize: 14, color: theme.text }, + bgRow: { + flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 4, + }, + bgLabel: { fontFamily: 'VT323', fontSize: 16, color: theme.text }, + previewBox: { padding: 4, minHeight: 160, alignItems: 'center', justifyContent: 'center' }, + previewImg: { width: '100%', height: 200, backgroundColor: '#fff' }, + actionRow: { + flexDirection: 'row', gap: 6, + paddingHorizontal: 6, paddingVertical: 6, + alignItems: 'center', backgroundColor: theme.bg, + }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16 }, + dialog: { padding: 16, minWidth: 320, gap: 12 }, + heading: { fontFamily: 'VT323', fontSize: 22, color: theme.text }, + msg: { fontFamily: 'VT323', fontSize: 14, color: theme.textMuted, textAlign: 'center' }, + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(192,192,192,0.6)', + alignItems: 'center', justifyContent: 'center', + }, +}); diff --git a/src/types.ts b/src/types.ts index 41b59da..2a94d95 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,7 +10,8 @@ export type Screen = | 'sourcepicker' | 'dropinbox' | 'recognizelasso' - | 'stitch'; + | 'stitch' + | 'layerstitch'; export type DitherMode = 'none' | 'fs1' | 'fs4' | 'atkinson'; From 5aca2e4407defafc33ca72ed42c5c8bdcbd70b76 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 14:44:18 +0000 Subject: [PATCH 23/28] Adopt Inkling's pendingButton routing so Stitch Layers reliably opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: tapping "Stitch Layers" landed on the default Browser screen instead of the layer picker. Root cause is the well-known race on cold-reloaded RN context: the host fires the button event around the time the JS bundle is reloading, but App.tsx's registerButtonListener in useEffect only attaches AFTER the first render. By then the SDK's lastButtonEventMsg replay may or may not still be active, so the first render shows the wrong screen. Inkling solves this with a module-level pendingButton: pendingButton.js — set/check/peek API index.js — button listener stashes msg.id synchronously App.tsx — initial-screen useState lazy initializer calls checkPendingButton() so the FIRST render is correct Imported the pattern. App.tsx now picks its initial screen based on the stashed button id; subsequent button presses still flow through PluginManager.registerButtonListener as before. Defensive layer listing - If PluginFileAPI.getLayers fails or returns nothing useful, fall back to inferring layers from the lassoed elements' layerNum so the LayerStitch screen always shows something to pick from. - Always include layer 0 (main) so there's at least one row. - More detailed FileLogger entries (per-layer element counts) so the next log shows exactly what the host returned. Bump 0.9.1 -> 0.9.2 / versionCode 28 -> 29. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- App.tsx | 16 +++++++++++++++- PluginConfig.json | 4 ++-- index.js | 13 +++++++++++-- pendingButton.d.ts | 3 +++ pendingButton.js | 22 ++++++++++++++++++++++ src/layerStitch.ts | 42 +++++++++++++++++++++++++++++++++++++----- 6 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 pendingButton.d.ts create mode 100644 pendingButton.js diff --git a/App.tsx b/App.tsx index 24271ff..ab1b2f4 100644 --- a/App.tsx +++ b/App.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import { PluginManager } from 'sn-plugin-lib'; +import { checkPendingButton } from './pendingButton'; import { ImageProcessor } from './src/imageProcessor'; import { BrowserScreen } from './src/screens/Browser'; import { CaptureScreen } from './src/screens/Capture'; @@ -21,8 +22,21 @@ const BUTTON_DROP = 3; const BUTTON_LASSO_RECOGNIZE = 5; const BUTTON_LASSO_STITCH_LAYERS = 6; +function initialScreenFromPendingButton(): Screen { + // Inkling pattern: index.js's button listener stores the pressed id + // in a module variable before App mounts. Reading it synchronously + // here means we render the right screen on the FIRST frame, instead + // of briefly flashing Browser before useEffect's listener fires. + const id = checkPendingButton(); + if (id === BUTTON_REFRESH) return 'refresh'; + if (id === BUTTON_DROP) return 'dropinbox'; + if (id === BUTTON_LASSO_RECOGNIZE) return 'recognizelasso'; + if (id === BUTTON_LASSO_STITCH_LAYERS) return 'layerstitch'; + return 'browser'; +} + export default function App(): React.JSX.Element { - const [screen, setScreen] = useState('browser'); + const [screen, setScreen] = useState(() => initialScreenFromPendingButton()); const [selectedEntry, setSelectedEntry] = useState(null); const [streamConfig, setStreamConfig] = useState(DEFAULT_STREAM_CONFIG); const [stitchSession, setStitchSession] = useState(null); diff --git a/PluginConfig.json b/PluginConfig.json index 22007c8..3b2245a 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.9.1", - "versionCode": "28", + "versionName": "0.9.2", + "versionCode": "29", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/index.js b/index.js index e159c64..d9335ef 100644 --- a/index.js +++ b/index.js @@ -4,6 +4,7 @@ import App from './App'; import { name as appName } from './app.json'; import { runSendLassoToMac } from './src/lassoExport'; import { loadStreamConfig } from './src/storage'; +import { setPendingButton } from './pendingButton'; AppRegistry.registerComponent(appName, () => App); @@ -90,12 +91,20 @@ PluginManager.registerButton(2, ['NOTE'], { PluginManager.registerButtonListener({ onButtonPress: (msg) => { // showType:0 buttons don't open a view, so we handle their action - // here in the headless context. The other buttons (showType:1) are - // routed by App.tsx after the view opens. + // here in the headless context. if (msg?.id === BUTTON_LASSO_SEND) { loadStreamConfig() .then((cfg) => runSendLassoToMac(cfg.lassoFormat ?? 'png')) .catch(() => {}); + return; + } + // showType:1 buttons open the plugin view. We stash the button id + // synchronously so App.tsx's initial render can route to the + // right screen instead of briefly flashing the default Browser. + // The SDK *also* replays the event into App.tsx's listener, but + // that's racy on cold reload — pendingButton wins. + if (msg?.id !== undefined && msg?.id !== null) { + setPendingButton(msg.id); } }, }); diff --git a/pendingButton.d.ts b/pendingButton.d.ts new file mode 100644 index 0000000..036f40e --- /dev/null +++ b/pendingButton.d.ts @@ -0,0 +1,3 @@ +export function setPendingButton(id: number | null): void; +export function checkPendingButton(): number | null; +export function peekPendingButton(): number | null; diff --git a/pendingButton.js b/pendingButton.js new file mode 100644 index 0000000..ff48e68 --- /dev/null +++ b/pendingButton.js @@ -0,0 +1,22 @@ +// Inkling-style synchronous routing channel from index.js's button +// listener to App.tsx's initial render. Necessary because the host +// dispatches the button event around the time the JS bundle (re)loads +// — if App relies only on the SDK's replay via registerButtonListener, +// the first render shows the wrong screen (or none) and the user sees +// the default Browser instead of the lasso-specific screen they tapped. + +let pendingButtonId = null; + +export const setPendingButton = (id) => { + pendingButtonId = id; +}; + +// Read AND clear — used by App.tsx on first mount to choose its +// initial screen, then it should not influence later mounts. +export const checkPendingButton = () => { + const val = pendingButtonId; + pendingButtonId = null; + return val; +}; + +export const peekPendingButton = () => pendingButtonId; diff --git a/src/layerStitch.ts b/src/layerStitch.ts index ec34ef0..b9ab5ad 100644 --- a/src/layerStitch.ts +++ b/src/layerStitch.ts @@ -39,10 +39,6 @@ export async function surveyLasso(): Promise { if (!elRes?.success || !Array.isArray(elRes.result)) return null; const elements: any[] = elRes.result; - const lyRes: any = await PluginFileAPI.getLayers(notePath, page); - if (!lyRes?.success || !Array.isArray(lyRes.result)) return null; - const rawLayers: any[] = lyRes.result; - const elementsByLayer = new Map(); for (const el of elements) { const ln = (el?.layerNum ?? 0) as number; @@ -50,7 +46,21 @@ export async function surveyLasso(): Promise { elementsByLayer.get(ln)!.push(el); } - const layers: LayerInfo[] = rawLayers + // Try getLayers first — gives us proper names + visibility. If it + // fails or returns nothing usable, fall back to whatever layerNums + // appear in the lasso so the user still has something to pick from. + let rawLayers: any[] = []; + try { + const lyRes: any = await PluginFileAPI.getLayers(notePath, page); + if (lyRes?.success && Array.isArray(lyRes.result)) { + rawLayers = lyRes.result; + } + FileLogger.log('LayerStitch', 'getLayers ->', { success: lyRes?.success, n: rawLayers.length }); + } catch (e: any) { + FileLogger.log('LayerStitch', 'getLayers threw', e?.message ?? String(e)); + } + + let layers: LayerInfo[] = rawLayers .map((l: any) => { const id = (l.layerId !== undefined ? l.layerId : l.layerNum) as number; return { @@ -64,8 +74,30 @@ export async function surveyLasso(): Promise { .filter((l) => l.layerId >= 0) .sort((a, b) => a.layerId - b.layerId); + if (layers.length === 0) { + // Fallback: infer layers from the lassoed elements' layerNums. + // Always include layer 0 (main) so the user has at least one row. + const ids = new Set([0]); + for (const ln of elementsByLayer.keys()) { + if (ln >= 0) ids.add(ln); + } + layers = Array.from(ids) + .sort((a, b) => a - b) + .map((id) => ({ + layerId: id, + name: `Layer ${id + 1}`, + isCurrent: id === 0, + isVisible: true, + count: elementsByLayer.get(id)?.length ?? 0, + })); + FileLogger.log('LayerStitch', 'using inferred layers', { ids: Array.from(ids) }); + } + FileLogger.log('LayerStitch', 'surveyLasso', { notePath, page, rect, layerCount: layers.length, totalEls: elements.length, + elementsByLayer: Object.fromEntries( + Array.from(elementsByLayer.entries()).map(([k, v]) => [k, v.length]), + ), }); return { notePath, page, rect, layers, elementsByLayer }; From 0e69859dfcc79128542856edad0ba4424e9d72f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 14:52:47 +0000 Subject: [PATCH 24/28] Log button-registration + presses to FileLogger so we can verify install Latest user log only shows runs of Send-to-Mac (id=4) and Recognize (id=5). No Stitch Layers (id=6) attempts, which means either: (a) the installed snplg is still pre-0.9.1 (button doesn't exist), or (b) the registration is silently failing on this firmware build. Switch all registerButton call-sites to a helper that logs success or failure to /sdcard/EmbedImage/log.txt instead of console.log (which isn't captured by FileLogger). Also log: - bundle start with versionName/versionCode (so we can confirm which build is actually loaded), and - every onButtonPress event so we see exactly what the lasso menu dispatches. Next `adb pull /sdcard/EmbedImage/log.txt` will start with something like: [embedimage] bundle start {"version":"0.9.3","code":"30"} [embedimage] registerButton OK {"id":6,"label":"Stitch Layers",...} [embedimage] onButtonPress {"id":6,...} If the registerButton line is missing or has FAIL, we know the host is rejecting the button payload and can fix it. If the onButtonPress line is missing, the button just isn't being tapped (probably hidden in the lasso menu). Bump 0.9.2 -> 0.9.3 / versionCode 29 -> 30. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- PluginConfig.json | 4 +-- index.js | 75 +++++++++++++++++------------------------------ 2 files changed, 29 insertions(+), 50 deletions(-) diff --git a/PluginConfig.json b/PluginConfig.json index 3b2245a..caca835 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.9.2", - "versionCode": "29", + "versionName": "0.9.3", + "versionCode": "30", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/index.js b/index.js index d9335ef..8b9dc7f 100644 --- a/index.js +++ b/index.js @@ -2,14 +2,20 @@ import { AppRegistry, Image } from 'react-native'; import { PluginManager } from 'sn-plugin-lib'; import App from './App'; import { name as appName } from './app.json'; +import pluginConfig from './PluginConfig.json'; import { runSendLassoToMac } from './src/lassoExport'; import { loadStreamConfig } from './src/storage'; +import { FileLogger } from './src/util/FileLogger'; import { setPendingButton } from './pendingButton'; AppRegistry.registerComponent(appName, () => App); PluginManager.init(); +FileLogger.raw('[embedimage] bundle start', { + version: pluginConfig.versionName, code: pluginConfig.versionCode, +}); + // Button IDs. App.tsx watches the most recent press and routes to the // matching headless screen if applicable. export const BUTTON_MAIN = 1; @@ -21,11 +27,6 @@ export const BUTTON_LASSO_STITCH_LAYERS = 6; // lasso toolbar — layer picke const ICON = Image.resolveAssetSource(require('./assets/icon.png')).uri; -// The Supernote host crashes (java.lang.NullPointerException inside -// PluginButtonAdapter / the lifecycle that registers the button) if -// nameMap or descMap is missing — its adapter does -// `new HashSet<>(button.nameMap.keySet())` without a null check. Provide -// at least the English fallback for every button. function mkName(label) { return { en: label, zh_CN: label, zh_TW: label, ja: label }; } @@ -40,69 +41,47 @@ const baseBtn = (id, label) => ({ showType: 1, }); -// Sidebar buttons (type=1). The host accepts these even with a null -// nameMap on current firmware, but we include it defensively in case -// a future build tightens the adapter the way the lasso one does. -PluginManager.registerButton(1, ['NOTE'], baseBtn(BUTTON_MAIN, 'Embed Image')) - .catch((e) => console.log('[embedimage] main button register failed:', e)); - -PluginManager.registerButton(1, ['NOTE'], baseBtn(BUTTON_REFRESH, 'Refresh Embed')) - .catch((e) => console.log('[embedimage] refresh button register failed:', e)); +// Helper that logs success/failure of each registration to the log +// file so the next adb pull tells us exactly what registered. +function regBtn(type, label, payload) { + return PluginManager.registerButton(type, ['NOTE'], payload) + .then((ok) => FileLogger.raw('[embedimage] registerButton OK', { id: payload.id, label, type, ok })) + .catch((e) => FileLogger.raw('[embedimage] registerButton FAIL', { id: payload.id, label, type, err: String(e?.message ?? e) })); +} -PluginManager.registerButton(1, ['NOTE'], baseBtn(BUTTON_DROP, 'Drop Inbox')) - .catch((e) => console.log('[embedimage] drop button register failed:', e)); +regBtn(1, 'Embed Image', baseBtn(BUTTON_MAIN, 'Embed Image')); +regBtn(1, 'Refresh Embed', baseBtn(BUTTON_REFRESH, 'Refresh Embed')); +regBtn(1, 'Drop Inbox', baseBtn(BUTTON_DROP, 'Drop Inbox')); -// Lasso-toolbar button (type=2). The host's area-selection adapter -// expects `editDataTypes` (which lasso content kinds the button applies -// to) and `nameMap`. With either missing it throws a HashSet NPE when -// the user opens the "..." menu and the menu crashes the note app. +// Lasso-toolbar buttons (type=2). The host's area-selection adapter +// expects `editDataTypes` and `nameMap` — without either it throws. // editDataTypes values per the SDK: // 0 handwritten strokes 1 title 2 image 3 text 4 link 5 shapes -// -// showType: 0 means "headless" — no plugin view opens when the button -// is pressed. Action runs straight from the button-press handler and -// reports back via Toast / Ratta dialog. This is the pattern from -// jpmoo/lassoexport, which is the only one that works for a real -// lasso → file pipeline (using saveStickerByLasso underneath). -PluginManager.registerButton(2, ['NOTE'], { +regBtn(2, 'Send to Mac', { ...baseBtn(BUTTON_LASSO_SEND, 'Send to Mac'), editDataTypes: [0, 1, 2, 3, 4, 5], - showType: 0, -}).catch((e) => console.log('[embedimage] lasso button register skipped:', e)); + showType: 0, // headless — runs straight from the button listener +}); -// Blended-in Inkling feature: lasso some handwriting, tap "Recognize", -// the plugin OCRs the strokes via PluginCommAPI.recognizeElements and -// drops the typed text back into the note. editDataTypes restricted -// to strokes/text (no point on pure pictures). showType: 1 because the -// recognize flow shows a progress dialog (OCR can take seconds). -PluginManager.registerButton(2, ['NOTE'], { +regBtn(2, 'Recognize', { ...baseBtn(BUTTON_LASSO_RECOGNIZE, 'Recognize'), - editDataTypes: [0, 1, 3], -}).catch((e) => console.log('[embedimage] recognize button register skipped:', e)); + editDataTypes: [0, 1, 3], // strokes / title / text +}); -// Lasso → choose which note layers to include → composite as PNG. -// Opens a layer picker (showType:1) so the user can toggle which layers -// participate and pick transparent / white background. -PluginManager.registerButton(2, ['NOTE'], { +regBtn(2, 'Stitch Layers', { ...baseBtn(BUTTON_LASSO_STITCH_LAYERS, 'Stitch Layers'), editDataTypes: [0, 1, 2, 3, 4, 5], -}).catch((e) => console.log('[embedimage] stitch-layers button register skipped:', e)); +}); PluginManager.registerButtonListener({ onButtonPress: (msg) => { - // showType:0 buttons don't open a view, so we handle their action - // here in the headless context. + FileLogger.raw('[embedimage] onButtonPress', msg); if (msg?.id === BUTTON_LASSO_SEND) { loadStreamConfig() .then((cfg) => runSendLassoToMac(cfg.lassoFormat ?? 'png')) .catch(() => {}); return; } - // showType:1 buttons open the plugin view. We stash the button id - // synchronously so App.tsx's initial render can route to the - // right screen instead of briefly flashing the default Browser. - // The SDK *also* replays the event into App.tsx's listener, but - // that's racy on cold reload — pendingButton wins. if (msg?.id !== undefined && msg?.id !== null) { setPendingButton(msg.id); } From 513b6e22b3ce1e471eeb7b1983e83d1cb8cfd702 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 14:58:29 +0000 Subject: [PATCH 25/28] Mirror Stitch Layers to sidebar so it's always visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.9.3 startup log confirmed all six buttons (incl. Stitch Layers id=6, type=2) register cleanly: [embedimage] registerButton OK {"id":6,"label":"Stitch Layers","type":2,"ok":true} …yet no onButtonPress event for id=6 ever fires. The Manta's lasso "..." popup has limited visible space; with Sticker + Send to Mac + Recognize already occupying it, the third plugin button scrolls off-screen and the user can't reach it. Add a SIDEBAR (type=1) duplicate registration. The lasso selection persists when the plugin view opens (Recognize already proves this), so the user can lasso → open sidebar → tap "Stitch Layers" and the LayerStitch screen sees the live selection just as if it had been triggered from the lasso menu. Distinct button id (7) so the two registrations don't collide; App.tsx routes both 6 and 7 to the same LayerStitch screen. The lasso-toolbar registration (id=6) stays in place too — for users who do scroll the popup or whose firmware shows more buttons. Bump 0.9.3 -> 0.9.4 / versionCode 30 -> 31. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- App.tsx | 5 ++++- PluginConfig.json | 4 ++-- index.js | 12 ++++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/App.tsx b/App.tsx index ab1b2f4..f80e975 100644 --- a/App.tsx +++ b/App.tsx @@ -20,7 +20,8 @@ import { DEFAULT_STREAM_CONFIG, Entry, Screen, StreamConfig } from './src/types' const BUTTON_REFRESH = 2; const BUTTON_DROP = 3; const BUTTON_LASSO_RECOGNIZE = 5; -const BUTTON_LASSO_STITCH_LAYERS = 6; +const BUTTON_LASSO_STITCH_LAYERS = 6; // lasso-toolbar entry +const BUTTON_STITCH_LAYERS_SIDE = 7; // sidebar entry (always-visible duplicate) function initialScreenFromPendingButton(): Screen { // Inkling pattern: index.js's button listener stores the pressed id @@ -32,6 +33,7 @@ function initialScreenFromPendingButton(): Screen { if (id === BUTTON_DROP) return 'dropinbox'; if (id === BUTTON_LASSO_RECOGNIZE) return 'recognizelasso'; if (id === BUTTON_LASSO_STITCH_LAYERS) return 'layerstitch'; + if (id === BUTTON_STITCH_LAYERS_SIDE) return 'layerstitch'; return 'browser'; } @@ -54,6 +56,7 @@ export default function App(): React.JSX.Element { else if (msg?.id === BUTTON_DROP) setScreen('dropinbox'); else if (msg?.id === BUTTON_LASSO_RECOGNIZE) setScreen('recognizelasso'); else if (msg?.id === BUTTON_LASSO_STITCH_LAYERS) setScreen('layerstitch'); + else if (msg?.id === BUTTON_STITCH_LAYERS_SIDE) setScreen('layerstitch'); }, }); return () => { diff --git a/PluginConfig.json b/PluginConfig.json index caca835..0930111 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.9.3", - "versionCode": "30", + "versionName": "0.9.4", + "versionCode": "31", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/index.js b/index.js index 8b9dc7f..91fe7e3 100644 --- a/index.js +++ b/index.js @@ -24,6 +24,7 @@ export const BUTTON_DROP = 3; export const BUTTON_LASSO_SEND = 4; // lasso toolbar — ship to Mac export const BUTTON_LASSO_RECOGNIZE = 5; // lasso toolbar — OCR -> insert text export const BUTTON_LASSO_STITCH_LAYERS = 6; // lasso toolbar — layer picker → image +export const BUTTON_STITCH_LAYERS_SIDE = 7; // sidebar duplicate of above (always visible) const ICON = Image.resolveAssetSource(require('./assets/icon.png')).uri; @@ -53,6 +54,17 @@ regBtn(1, 'Embed Image', baseBtn(BUTTON_MAIN, 'Embed Image')); regBtn(1, 'Refresh Embed', baseBtn(BUTTON_REFRESH, 'Refresh Embed')); regBtn(1, 'Drop Inbox', baseBtn(BUTTON_DROP, 'Drop Inbox')); +// Stitch Layers also lives as a SIDEBAR button. The lasso "..." popup +// in the Manta toolbar has limited visible space; with Sticker + our +// Send to Mac + Recognize already there, the lasso version may scroll +// off-screen and look "missing". The sidebar entry is always visible +// — lasso state is preserved when the plugin view opens, so the user +// can lasso → open sidebar → Stitch Layers and the picker still sees +// the selection. The lasso-toolbar version below stays registered for +// users who scroll to it. The two registrations need distinct ids; +// App.tsx routes both to the same LayerStitch screen. +regBtn(1, 'Stitch Layers', baseBtn(BUTTON_STITCH_LAYERS_SIDE, 'Stitch Layers')); + // Lasso-toolbar buttons (type=2). The host's area-selection adapter // expects `editDataTypes` and `nameMap` — without either it throws. // editDataTypes values per the SDK: From 8965d9c1e486118f116fb9dfe1fe11b44262b38b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 15:04:42 +0000 Subject: [PATCH 26/28] Persist pendingButton across JS contexts so view routes correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.9.4 log nailed the actual bug: 23:00:21 bundle start (background context) 23:00:37 onButtonPress {id:7} ← handled here, sets in-memory var 23:00:47 bundle start AGAIN ← fresh UI context for the plugin view (no [LayerStitch] survey log — App.tsx routed to default Browser) The host runs index.js TWICE — once as a background context that registers buttons and the press listener, and again as a fresh UI context when the plugin view opens. They have disjoint JS module state, so the in-memory pendingButtonId set during the press is gone by the time App.tsx mounts in the UI bundle. That's why all our "opens a view" lasso buttons silently fall through to the default Browser screen. Fix: back pendingButton with SharedPreferences via a synchronous native method. - ImageProcessorModule.setPendingButton(id) — async write - ImageProcessorModule.getAndClearPendingButton() — synchronous read with @ReactMethod(isBlockingSynchronousMethod = true), returns a String so App.tsx's useState lazy initializer can read it before the first render frame. - pendingButton.js now writes via the native setter AND keeps an in-memory cache for same-context navigation; checkPendingButton consults SharedPreferences first. Bump 0.9.4 -> 0.9.5 / versionCode 31 -> 32. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- PluginConfig.json | 4 +- .../com/embedimage/ImageProcessorModule.kt | 30 +++++++++++++++ pendingButton.js | 38 ++++++++++++++----- 3 files changed, 61 insertions(+), 11 deletions(-) diff --git a/PluginConfig.json b/PluginConfig.json index 0930111..7d0f54a 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.9.4", - "versionCode": "31", + "versionName": "0.9.5", + "versionCode": "32", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt b/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt index 551bbfe..e0ba3a2 100644 --- a/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt +++ b/android/app/src/main/java/com/embedimage/ImageProcessorModule.kt @@ -360,6 +360,36 @@ class ImageProcessorModule(reactContext: ReactApplicationContext) : reactApplicationContext.getSharedPreferences("embedimage_prefs", android.content.Context.MODE_PRIVATE) } + // Cross-bundle pending button handoff. The host runs index.js once + // in a background context to register buttons + listener, and once + // more in a UI context when the view opens — they're SEPARATE RN + // bundles with disjoint module state. So the button press fired in + // the background can't be read via a JS-module variable in the UI + // bundle. SharedPreferences bridges them. + // + // The getter is synchronous (returns a Kotlin String) so App.tsx's + // useState initializer can read it before any render frame. + @ReactMethod(isBlockingSynchronousMethod = true) + fun getAndClearPendingButton(): String { + return try { + val v = prefs.getString("pendingButton", "") ?: "" + if (v.isNotEmpty()) prefs.edit().remove("pendingButton").apply() + v + } catch (_: Throwable) { + "" + } + } + + @ReactMethod + fun setPendingButton(id: Int, promise: Promise) { + try { + prefs.edit().putString("pendingButton", id.toString()).apply() + promise.resolve(true) + } catch (e: Throwable) { + promise.reject("E_PENDING", e.message ?: e.toString(), e) + } + } + @ReactMethod fun getConfigValue(key: String, promise: Promise) { try { diff --git a/pendingButton.js b/pendingButton.js index ff48e68..81c6e11 100644 --- a/pendingButton.js +++ b/pendingButton.js @@ -1,20 +1,40 @@ -// Inkling-style synchronous routing channel from index.js's button -// listener to App.tsx's initial render. Necessary because the host -// dispatches the button event around the time the JS bundle (re)loads -// — if App relies only on the SDK's replay via registerButtonListener, -// the first render shows the wrong screen (or none) and the user sees -// the default Browser instead of the lasso-specific screen they tapped. +// Synchronous routing channel from index.js's button listener to +// App.tsx's initial render. The complication: the host runs index.js +// twice — once in a background context (button registrations + press +// listener) and again in a fresh UI context when the view opens. They +// have disjoint JS module state, so a plain in-memory variable here +// would be reset by the time App.tsx renders. +// +// We therefore back it with SharedPreferences via a synchronous native +// method, with an in-memory cache for same-context navigation. +import { NativeModules } from 'react-native'; + +const { ImageProcessor } = NativeModules; let pendingButtonId = null; export const setPendingButton = (id) => { pendingButtonId = id; + // Fire-and-forget; the value lives in SharedPreferences so the UI + // context can read it even if our JS module state was reset. + try { + ImageProcessor?.setPendingButton?.(id); + } catch (_) {} }; -// Read AND clear — used by App.tsx on first mount to choose its -// initial screen, then it should not influence later mounts. +// Read AND clear. Called by App.tsx on first mount. Tries the native +// store first (works across context boundaries) then falls back to +// the in-memory variable (works for re-mounts within the same context). export const checkPendingButton = () => { - const val = pendingButtonId; + let val = null; + try { + const raw = ImageProcessor?.getAndClearPendingButton?.(); + if (typeof raw === 'string' && raw.length > 0) { + const n = parseInt(raw, 10); + if (!Number.isNaN(n)) val = n; + } + } catch (_) {} + if (val === null) val = pendingButtonId; pendingButtonId = null; return val; }; From 677e3e0f8b5f599539e36f609456a78d1c1acf3e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 15:14:02 +0000 Subject: [PATCH 27/28] Log App mount + initial-screen routing so we can see where it stops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.9.5 log shows the BG bundle starting and onButtonPress {id:7} firing, but then nothing — no UI bundle start, no App mount, no LayerStitch survey. Need to know whether App is mounting (with stale state) or never mounts at all. Add three FileLogger entries: - "App mounted" in App.tsx useEffect - "App initialScreen { pendingId, chosen }" in the useState lazy init - "App onButtonPress { id }" inside the registerButtonListener cb Next adb pull will tell us: bundle started in UI ctx? App mounted? Routed to layerstitch or browser? pendingButton value was read or came back null? Bump 0.9.5 -> 0.9.6 / versionCode 32 -> 33. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- App.tsx | 21 +++++++++++---------- PluginConfig.json | 4 ++-- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/App.tsx b/App.tsx index f80e975..6cb347a 100644 --- a/App.tsx +++ b/App.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { PluginManager } from 'sn-plugin-lib'; import { checkPendingButton } from './pendingButton'; import { ImageProcessor } from './src/imageProcessor'; +import { FileLogger } from './src/util/FileLogger'; import { BrowserScreen } from './src/screens/Browser'; import { CaptureScreen } from './src/screens/Capture'; import { DropInbox } from './src/screens/DropInbox'; @@ -24,17 +25,15 @@ const BUTTON_LASSO_STITCH_LAYERS = 6; // lasso-toolbar entry const BUTTON_STITCH_LAYERS_SIDE = 7; // sidebar entry (always-visible duplicate) function initialScreenFromPendingButton(): Screen { - // Inkling pattern: index.js's button listener stores the pressed id - // in a module variable before App mounts. Reading it synchronously - // here means we render the right screen on the FIRST frame, instead - // of briefly flashing Browser before useEffect's listener fires. const id = checkPendingButton(); - if (id === BUTTON_REFRESH) return 'refresh'; - if (id === BUTTON_DROP) return 'dropinbox'; - if (id === BUTTON_LASSO_RECOGNIZE) return 'recognizelasso'; - if (id === BUTTON_LASSO_STITCH_LAYERS) return 'layerstitch'; - if (id === BUTTON_STITCH_LAYERS_SIDE) return 'layerstitch'; - return 'browser'; + let chosen: Screen = 'browser'; + if (id === BUTTON_REFRESH) chosen = 'refresh'; + else if (id === BUTTON_DROP) chosen = 'dropinbox'; + else if (id === BUTTON_LASSO_RECOGNIZE) chosen = 'recognizelasso'; + else if (id === BUTTON_LASSO_STITCH_LAYERS) chosen = 'layerstitch'; + else if (id === BUTTON_STITCH_LAYERS_SIDE) chosen = 'layerstitch'; + FileLogger.raw('[embedimage] App initialScreen', { pendingId: id, chosen }); + return chosen; } export default function App(): React.JSX.Element { @@ -44,6 +43,7 @@ export default function App(): React.JSX.Element { const [stitchSession, setStitchSession] = useState(null); useEffect(() => { + FileLogger.raw('[embedimage] App mounted'); ImageProcessor?.cleanupCache?.().catch(() => {}); (async () => { const cfg = await loadStreamConfig(); @@ -52,6 +52,7 @@ export default function App(): React.JSX.Element { const sub = PluginManager.registerButtonListener({ onButtonPress: (msg: any) => { + FileLogger.raw('[embedimage] App onButtonPress', { id: msg?.id }); if (msg?.id === BUTTON_REFRESH) setScreen('refresh'); else if (msg?.id === BUTTON_DROP) setScreen('dropinbox'); else if (msg?.id === BUTTON_LASSO_RECOGNIZE) setScreen('recognizelasso'); diff --git a/PluginConfig.json b/PluginConfig.json index 7d0f54a..243c03c 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.9.5", - "versionCode": "32", + "versionName": "0.9.6", + "versionCode": "33", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" From 19fe385976b194b05d78fcdaeb0de9a6ef5ba68b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 15:21:05 +0000 Subject: [PATCH 28/28] Add headless Quick Stitch buttons that don't need a view to open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: 0.9.6 log shows App.initialScreen running and routing to layerstitch correctly, but the screen never visibly shows up (no App.mounted log either). The view either doesn't open or mounts hidden — same routing race we've been chasing. Stop fighting the view lifecycle. Add two headless lasso-toolbar buttons that mirror the showType:0 pattern from jpmoo's lassoexport (the only pattern we KNOW works reliably for this firmware): - "Stitch (transparent)" (id=8): convertElement2Sticker on the lasso, generateStickerThumbnail to PNG, insertImage into the current note, Toast on success / Ratta dialog on failure. - "Stitch (white BG)" (id=9): same but flatten over white via the ImageProcessor.flattenOntoBg native method first. No view, no pendingButton handoff, no App.tsx round trip. Press the button → it works → Toast confirms. The view-based "Stitch Layers" picker stays registered for users who want layer selection (and as a regression target so we can finish debugging the routing later). Implementation lives in src/quickStitch.ts; index.js wires the two button IDs to runQuickStitch(true|false). Bump 0.9.6 -> 0.9.7 / versionCode 33 -> 34. https://claude.ai/code/session_01FTRgWnGRMVpuvHBVnoRMkh --- PluginConfig.json | 4 +-- index.js | 27 ++++++++++++++ src/quickStitch.ts | 90 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 src/quickStitch.ts diff --git a/PluginConfig.json b/PluginConfig.json index 243c03c..7a9e80d 100644 --- a/PluginConfig.json +++ b/PluginConfig.json @@ -2,8 +2,8 @@ "name": "Embed Image", "desc": "Browse images (incl. WebDAV/SD), preview with fade/brightness/contrast/gamma, and embed into the current note.", "iconPath": "./assets/icon.png", - "versionName": "0.9.6", - "versionCode": "33", + "versionName": "0.9.7", + "versionCode": "34", "pluginID": "embedimagev0001a", "pluginKey": "embedimage", "jsMainPath": "index" diff --git a/index.js b/index.js index 91fe7e3..cddfb07 100644 --- a/index.js +++ b/index.js @@ -4,6 +4,7 @@ import App from './App'; import { name as appName } from './app.json'; import pluginConfig from './PluginConfig.json'; import { runSendLassoToMac } from './src/lassoExport'; +import { runQuickStitch } from './src/quickStitch'; import { loadStreamConfig } from './src/storage'; import { FileLogger } from './src/util/FileLogger'; import { setPendingButton } from './pendingButton'; @@ -25,6 +26,8 @@ export const BUTTON_LASSO_SEND = 4; // lasso toolbar — ship to Mac export const BUTTON_LASSO_RECOGNIZE = 5; // lasso toolbar — OCR -> insert text export const BUTTON_LASSO_STITCH_LAYERS = 6; // lasso toolbar — layer picker → image export const BUTTON_STITCH_LAYERS_SIDE = 7; // sidebar duplicate of above (always visible) +export const BUTTON_QUICK_STITCH_TRANSP = 8; // lasso toolbar — headless stitch (transparent BG) +export const BUTTON_QUICK_STITCH_WHITE = 9; // lasso toolbar — headless stitch (white BG) const ICON = Image.resolveAssetSource(require('./assets/icon.png')).uri; @@ -85,6 +88,22 @@ regBtn(2, 'Stitch Layers', { editDataTypes: [0, 1, 2, 3, 4, 5], }); +// Headless "just do it" variants — same plumbing as Send to Mac +// (showType:0, runs straight from the button-press handler, no plugin +// view to open). Bypasses the routing race entirely. The view-based +// "Stitch Layers" picker stays for users who want layer selection. +regBtn(2, 'Stitch (transparent)', { + ...baseBtn(BUTTON_QUICK_STITCH_TRANSP, 'Stitch (transparent)'), + editDataTypes: [0, 1, 2, 3, 4, 5], + showType: 0, +}); + +regBtn(2, 'Stitch (white BG)', { + ...baseBtn(BUTTON_QUICK_STITCH_WHITE, 'Stitch (white BG)'), + editDataTypes: [0, 1, 2, 3, 4, 5], + showType: 0, +}); + PluginManager.registerButtonListener({ onButtonPress: (msg) => { FileLogger.raw('[embedimage] onButtonPress', msg); @@ -94,6 +113,14 @@ PluginManager.registerButtonListener({ .catch(() => {}); return; } + if (msg?.id === BUTTON_QUICK_STITCH_TRANSP) { + runQuickStitch(true).catch(() => {}); + return; + } + if (msg?.id === BUTTON_QUICK_STITCH_WHITE) { + runQuickStitch(false).catch(() => {}); + return; + } if (msg?.id !== undefined && msg?.id !== null) { setPendingButton(msg.id); } diff --git a/src/quickStitch.ts b/src/quickStitch.ts new file mode 100644 index 0000000..8b81b8a --- /dev/null +++ b/src/quickStitch.ts @@ -0,0 +1,90 @@ +import { FileUtils, NativeUIUtils, PluginCommAPI, PluginManager, PluginNoteAPI } from 'sn-plugin-lib'; +import { flattenOntoBg } from './imageProcessor'; +import { FileLogger } from './util/FileLogger'; + +// Headless lasso → sticker → PNG, inserted into the note. Same +// pipeline jpmoo's lassoexport uses (which is the only one that +// reliably works for "save lasso as image"). Runs entirely from the +// button-press handler in index.js — no plugin view, no routing race. +// +// Two flavours exposed below: transparent background (drop straight in, +// nothing covers existing content) and white background (handy when +// pasting into a darker page or shipping to a viewer that doesn't +// handle alpha well). + +async function exportLasso(transparentBg: boolean): Promise { + const pluginDir = await PluginManager.getPluginDirPath(); + if (!pluginDir) throw new Error('cannot resolve plugin directory'); + const trimmed = pluginDir.replace(/\/+$/, ''); + const stamp = Date.now(); + const stickerPath = `${trimmed}/qstitch-${stamp}.sticker`; + const pngPath = `${trimmed}/qstitch-${stamp}.png`; + const flatPath = `${trimmed}/qstitch-${stamp}-flat.png`; + + // Clean any leftover stickers from prior runs. + try { PluginCommAPI.clearElementCache(); } catch {} + try { + const existing: any = await FileUtils.listFiles(pluginDir); + if (Array.isArray(existing)) { + for (const entry of existing) { + const name = typeof entry === 'string' ? entry : entry?.path; + if (typeof name === 'string' && name.endsWith('.sticker')) { + try { await FileUtils.deleteFile(name.startsWith('/') ? name : `${trimmed}/${name}`); } catch {} + } + } + } + } catch {} + + FileLogger.raw('[embedimage] QuickStitch saveStickerByLasso ->', stickerPath); + const saveRes: any = await PluginCommAPI.saveStickerByLasso(stickerPath); + FileLogger.raw('[embedimage] QuickStitch saveStickerByLasso result:', JSON.stringify(saveRes)); + if (!saveRes?.success) { + throw new Error(saveRes?.error?.message ?? 'saveStickerByLasso failed (lasso may be empty)'); + } + + const sizeRes: any = await PluginCommAPI.getStickerSize(stickerPath); + FileLogger.raw('[embedimage] QuickStitch getStickerSize ->', JSON.stringify(sizeRes)); + const size = sizeRes?.result; + if (!size?.width || !size?.height) { + throw new Error(`getStickerSize returned ${JSON.stringify(size)}`); + } + + const thumbRes: any = await PluginCommAPI.generateStickerThumbnail(stickerPath, pngPath, size); + FileLogger.raw('[embedimage] QuickStitch generateStickerThumbnail ->', JSON.stringify(thumbRes)); + if (thumbRes?.success === false) { + throw new Error(thumbRes?.error?.message ?? 'generateStickerThumbnail failed'); + } + + if (transparentBg) { + try { await FileUtils.deleteFile(stickerPath); } catch {} + return pngPath; + } + await flattenOntoBg(pngPath, flatPath, { r: 255, g: 255, b: 255 }); + try { await FileUtils.deleteFile(stickerPath); } catch {} + return flatPath; +} + +export async function runQuickStitch(transparentBg: boolean): Promise { + FileLogger.raw('[embedimage] runQuickStitch start', { transparentBg }); + try { + const pngPath = await exportLasso(transparentBg); + FileLogger.raw('[embedimage] runQuickStitch composed ->', pngPath); + const ins: any = await PluginNoteAPI.insertImage(pngPath); + FileLogger.raw('[embedimage] runQuickStitch insertImage ->', JSON.stringify(ins)); + if (!ins || ins.success === false) { + throw new Error(ins?.error?.message ?? 'insertImage failed'); + } + try { await PluginNoteAPI.saveCurrentNote(); } catch {} + try { + const ToastAndroid = require('react-native').ToastAndroid; + ToastAndroid.show( + `Stitched lasso (${transparentBg ? 'transparent' : 'white'} BG)`, + ToastAndroid.LONG, + ); + } catch {} + } catch (e: any) { + const msg = e?.message ?? String(e); + FileLogger.raw('[embedimage] runQuickStitch failed:', msg); + try { NativeUIUtils.showRattaDialog(`Quick Stitch failed: ${msg}`, '', 'OK', false); } catch {} + } +}