Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
09b96da
Add preview-with-fade, JPEG support, and white-overlay bake
claude May 23, 2026
5782683
Add folder navigator, system picker (WebDAV), B/C/gamma adjustments
claude May 23, 2026
d84a791
Add thumbnail size +/- and clarify WebDAV refresh limitation
claude May 23, 2026
a8511dd
Expose SD card / external storage as additional roots
claude May 23, 2026
f22a3b6
Add live screen-capture streaming from a Mac, Settings, adjustment-ta…
claude May 23, 2026
df54eee
Fix plugin crash on new Manta beta; move stream adjustments to Mac side
claude May 23, 2026
3a67b24
gitignore: ignore Python __pycache__/ and .pyc files
claude May 23, 2026
2e2444c
Bypass cleartext-HTTP block via raw-socket native fetch
claude May 23, 2026
d57e154
Make refreshing the embedded image one-tap
claude May 23, 2026
b97dea4
Resolution multiplier, connection dot, Manta-side source picker, BiRe…
claude May 23, 2026
238a786
Win95 makeover, CRT region picker, dither, watch folder, lasso -> Mac…
claude May 23, 2026
030c5e2
Fix BiRefNet on MPS + menu drop-downs hiding behind UI
claude May 23, 2026
f6f5d3f
Harden Refresh / Lasso / DropInbox flows; catch async button-reg fail…
claude May 23, 2026
48135e3
Fix lasso "..." menu crash by giving buttons nameMap + editDataTypes
claude May 23, 2026
7efab0a
Fix lasso send to Mac, and refresh-embed losing the image
claude May 23, 2026
a001edd
Refresh embed: drop modifyElements path, insert-then-delete with logging
claude May 23, 2026
d081343
Refresh: clone existing element; SendLasso: log every step
claude May 23, 2026
7e00991
Refresh: insert+move fallback; SendLasso: switch to generateNotePng
claude May 23, 2026
92f4861
Blend in Inkling: OCR-lasso-to-text + persistent file logger
claude May 23, 2026
404b392
Send to Mac via jpmoo's lasso → sticker → PNG pipeline + showType:0
claude May 23, 2026
211a88b
Port Inkling's StitchEditor + native two-image compositor
claude May 23, 2026
6a0c806
Lasso → pick note layers → composite as image (Inkling-style, on canvas)
claude May 23, 2026
5aca2e4
Adopt Inkling's pendingButton routing so Stitch Layers reliably opens
claude May 23, 2026
0e69859
Log button-registration + presses to FileLogger so we can verify install
claude May 23, 2026
513b6e2
Mirror Stitch Layers to sidebar so it's always visible
claude May 23, 2026
8965d9c
Persist pendingButton across JS contexts so view routes correctly
claude May 23, 2026
677e3e0
Log App mount + initial-screen routing so we can see where it stops
claude May 23, 2026
19fe385
Add headless Quick Stitch buttons that don't need a view to open
claude May 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ dist/
*.log
.metro-health-check*

# Python
__pycache__/
*.pyc

# Android
android/.gradle/
android/build/
Expand Down
364 changes: 145 additions & 219 deletions App.tsx
Original file line number Diff line number Diff line change
@@ -1,229 +1,155 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
FlatList,
Image,
Pressable,
SafeAreaView,
StyleSheet,
Text,
ToastAndroid,
View,
} from 'react-native';
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 IMAGES_DIR = '/storage/emulated/0/Document/Images';

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;
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';
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';
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';

// 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_RECOGNIZE = 5;
const BUTTON_LASSO_STITCH_LAYERS = 6; // lasso-toolbar entry
const BUTTON_STITCH_LAYERS_SIDE = 7; // sidebar entry (always-visible duplicate)

function initialScreenFromPendingButton(): Screen {
const id = checkPendingButton();
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;
}

function isImage(name: string): boolean {
const lower = name.toLowerCase();
return IMAGE_EXTS.some((ext) => lower.endsWith(ext));
}
export default function App(): React.JSX.Element {
const [screen, setScreen] = useState<Screen>(() => initialScreenFromPendingButton());
const [selectedEntry, setSelectedEntry] = useState<Entry | null>(null);
const [streamConfig, setStreamConfig] = useState<StreamConfig>(DEFAULT_STREAM_CONFIG);
const [stitchSession, setStitchSession] = useState<StitchSession | null>(null);

function sortItems(items: ImageItem[], sort: SortKey): ImageItem[] {
const copy = items.slice();
const byName = (a: ImageItem, b: ImageItem) =>
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;
}
useEffect(() => {
FileLogger.raw('[embedimage] App mounted');
ImageProcessor?.cleanupCache?.().catch(() => {});
(async () => {
const cfg = await loadStreamConfig();
setStreamConfig(cfg);
})();

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');
else if (msg?.id === BUTTON_LASSO_STITCH_LAYERS) setScreen('layerstitch');
else if (msg?.id === BUTTON_STITCH_LAYERS_SIDE) setScreen('layerstitch');
},
});
return () => {
sub?.remove?.();
};
}, []);

export default function App(): React.JSX.Element {
const [items, setItems] = useState<ImageItem[]>([]);
const [sort, setSort] = useState<SortKey>('date_desc');
const [status, setStatus] = useState<string>('starting…');
const [busy, setBusy] = useState(false);

const load = useCallback(async () => {
setStatus(`checking ${IMAGES_DIR}`);
try {
let exists = false;
try {
exists = await FileUtils.exists(IMAGES_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}`);
return;
}
}

setStatus('listing files…');
let entries: any = null;
try {
entries = await FileUtils.listFiles(IMAGES_DIR);
} catch (e: any) {
setStatus(`listFiles() threw: ${e?.message ?? e}`);
return;
}

const list: any[] = Array.isArray(entries) ? entries : [];
const imgs: ImageItem[] = [];
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 name = basename(path);
if (!isImage(name)) continue;
imgs.push({ name, path });
}
setItems(imgs);
setStatus(`found ${imgs.length} image${imgs.length === 1 ? '' : 's'} (raw entries: ${list.length})`);
} catch (err: any) {
setStatus(`unexpected: ${err?.message ?? err}`);
}
const openPreview = useCallback((entry: Entry) => {
setSelectedEntry(entry);
setScreen('preview');
}, []);

useEffect(() => {
load();
}, [load]);

const sorted = useMemo(() => sortItems(items, sort), [items, sort]);

const onPick = useCallback(async (item: ImageItem) => {
if (busy) return;
setBusy(true);
setStatus(`embedding ${item.name}…`);
try {
const res: any = await PluginNoteAPI.insertImage(item.path);
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 ${item.name}`, ToastAndroid.SHORT, ToastAndroid.BOTTOM);
} catch {}
PluginManager.closePluginView().catch(() => {});
} catch (err: any) {
setStatus(`insert threw: ${err?.message ?? err}`);
} finally {
setBusy(false);
}
}, [busy]);
const goBrowser = useCallback(() => {
setSelectedEntry(null);
setScreen('browser');
}, []);

if (screen === 'refresh') {
return <RefreshScreen />;
}

if (screen === 'dropinbox') {
return <DropInbox onClose={goBrowser} />;
}

if (screen === 'recognizelasso') {
return <RecognizeLasso />;
}

if (screen === 'layerstitch') {
return <LayerStitch onClose={goBrowser} />;
}

if (screen === 'stitch' && stitchSession) {
return (
<StitchEditor
session={stitchSession}
onCancel={() => { setStitchSession(null); goBrowser(); }}
onInserted={() => {
setStitchSession(null);
PluginManager.closePluginView().catch(() => {});
}}
/>
);
}

if (screen === 'preview' && selectedEntry) {
return <PreviewScreen entry={selectedEntry} onBack={goBrowser} />;
}

if (screen === 'settings') {
return (
<SettingsScreen
onBack={goBrowser}
onSaved={(cfg) => setStreamConfig(cfg)}
/>
);
}

if (screen === 'capture') {
return (
<CaptureScreen
config={streamConfig}
onConfigChange={setStreamConfig}
onBack={goBrowser}
onOpenSettings={() => setScreen('settings')}
onOpenSourcePicker={() => setScreen('sourcepicker')}
/>
);
}

if (screen === 'sourcepicker') {
return (
<SourcePicker
baseUrl={baseUrl(streamConfig)}
onClose={() => setScreen('capture')}
onChanged={() => setScreen('capture')}
/>
);
}

return (
<SafeAreaView style={styles.root}>
<View style={styles.header}>
<Text style={styles.title}>Embed PNG</Text>
<Pressable style={styles.btn} onPress={() => PluginManager.closePluginView().catch(() => {})}>
<Text style={styles.btnTxt}>Close</Text>
</Pressable>
</View>

<Text style={styles.status}>{status}</Text>

<View style={styles.sortBar}>
<Text style={styles.sortLabel}>Sort:</Text>
{SORT_OPTIONS.map((opt) => {
const active = opt.key === sort;
return (
<Pressable
key={opt.key}
onPress={() => setSort(opt.key)}
style={[styles.chip, active && styles.chipActive]}
>
<Text style={[styles.chipTxt, active && styles.chipTxtActive]}>{opt.label}</Text>
</Pressable>
);
})}
<View style={{ flex: 1 }} />
<Pressable onPress={load} style={styles.btn}>
<Text style={styles.btnTxt}>Refresh</Text>
</Pressable>
</View>

{sorted.length === 0 ? (
<View style={styles.center}>
<Text style={styles.empty}>No PNG files yet.</Text>
<Text style={styles.emptySub}>{IMAGES_DIR}</Text>
</View>
) : (
<FlatList
data={sorted}
keyExtractor={(it) => it.path}
numColumns={3}
contentContainerStyle={styles.grid}
renderItem={({ item }) => (
<Pressable style={styles.tile} onPress={() => onPick(item)} disabled={busy}>
<Image
source={{ uri: 'file://' + item.path }}
style={styles.thumb}
resizeMode="cover"
/>
<Text numberOfLines={2} style={styles.tileName}>{item.name}</Text>
</Pressable>
)}
/>
)}

{busy ? (
<View style={styles.overlay}>
<ActivityIndicator size="large" color="#fff" />
</View>
) : null}
</SafeAreaView>
<BrowserScreen
onPickFile={openPreview}
onOpenSettings={() => setScreen('settings')}
onOpenCapture={() => setScreen('capture')}
onClose={() => {}}
busy={false}
onStitchReady={(session) => {
setStitchSession(session);
setScreen('stitch');
}}
/>
);
}

const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: '#fff' },
header: {
flexDirection: 'row', alignItems: 'center',
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',
},
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' },
btnTxt: { fontSize: 14, color: '#000' },
grid: { padding: 8 },
tile: { flex: 1 / 3, padding: 6, alignItems: 'center' },
thumb: { width: '100%', aspectRatio: 1, backgroundColor: '#eee' },
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' },
overlay: {
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
alignItems: 'center', justifyContent: 'center',
},
});
8 changes: 4 additions & 4 deletions PluginConfig.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"name": "Embed PNG",
"desc": "Browse PNG files in Document/Images and embed them into the current note.",
"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.2.0",
"versionCode": "7",
"versionName": "0.9.7",
"versionCode": "34",
"pluginID": "embedimagev0001a",
"pluginKey": "embedimage",
"jsMainPath": "index"
Expand Down
Loading