diff --git a/.claude/skills/supernote-plugin-dev/references/make-space.md b/.claude/skills/supernote-plugin-dev/references/make-space.md index 851ef94..08de228 100644 --- a/.claude/skills/supernote-plugin-dev/references/make-space.md +++ b/.claude/skills/supernote-plugin-dev/references/make-space.md @@ -226,7 +226,12 @@ icon, requires the beta), catalogued informally on GitHub - Repo needs a `LICENSE` — any future listing (InkHub or community catalogue) expects clear terms. - `PluginConfig.json` metadata (`name`, `desc`, `iconPath`, `versionName`, `homepage`) already reads like a store listing — keep it accurate on every release, it's the likely source InkHub - would pull from. + would pull from. `name` was the raw `pluginKey` string (`sn_make_space`) until the presentable- + name pass — it's independent of `pluginKey`/`pluginID` (those stay untouched, changing them would + confuse the host's registry for anyone who already installed the plugin) and is purely the + human-facing title shown in Settings → Apps → Plugins and the plugin detail screen. Distinct + icons per button (`assets/icon-below.png`/`icon-above.png`) plus a redesigned `assets/icon.png` + app icon replaced the generic puzzle-piece template default. - Permission hygiene: this plugin declares no `uses-permissions` in `PluginConfig.json` — every SDK call it makes (`getPageDisplaySize`, `lassoElements`, `setLassoBoxState`) operates on the currently-open file via context, not a `filePath` argument. It briefly did need one by accident: diff --git a/App.tsx b/App.tsx index 00379d0..b27bcec 100644 --- a/App.tsx +++ b/App.tsx @@ -3,8 +3,10 @@ * * Full-screen, transparent overlay framed by a thick grey border. Press and * drag with the pen: a thin guide line follows to show exactly where the cut - * will be; lift to commit — everything on the current NOTE page below that line - * is lassoed and the plugin closes so you can drag the selection to make space. + * will be; lift to commit — everything on the current NOTE page above or + * below that line (whichever sidebar button opened the plugin — id 100=below, + * 101=above, see `direction` below) is lassoed and the plugin closes so you + * can drag the selection to make space. * * The move and its undo are native NOTE behavior — this plugin only builds the * selection. See .claude/skills/supernote-plugin-dev/references/make-space.md. @@ -24,8 +26,10 @@ import { View, } from 'react-native'; import {useTranslation} from 'react-i18next'; +import {PluginManager} from 'sn-plugin-lib'; -import {computeLassoRect} from './src/makeSpace'; +import {checkPendingDirection} from './index'; +import {computeLassoRect, type CutDirection} from './src/makeSpace'; import {dismissIntro, isIntroDismissed} from './src/prefs'; import {closePluginView, getPageDisplaySize, lassoElements} from './src/sdk'; @@ -82,6 +86,15 @@ function App(): React.JSX.Element { // lasso/close window so it doesn't flash back on (a brief flash just ghosts on // e-ink). Set synchronously on release so there's no frame where it shows. const [committing, setCommitting] = useState(false); + // Which side of the cut line gets selected — set by which sidebar button + // (100=below, 101=above) opened the plugin. Seeded from the pending ID + // stashed by index.js's module-level listener (covers the very first open, + // before this component existed to register its own listener below); + // every later press is caught live by the listener in the mount effect, + // since PluginHost reuses this App instance instead of remounting it. + const [direction, setDirection] = useState( + () => checkPendingDirection() ?? 'below', + ); // Measured height of the overlay (DP). Seeded with the window height so the // first commit still maps sensibly if it lands before onLayout fires. @@ -99,7 +112,22 @@ function App(): React.JSX.Element { const ctx = await loadContext('mount'); setFailed(ctx == null); })(); - return () => log('App unmounted'); + // Catches every button press AFTER this first mount — this effect only + // ever runs once (App instance reuse, see class doc), but the listener + // itself stays live for the component's whole lifetime, so it keeps + // receiving events across opens/closes. The pending-ID read above only + // covers the very first press, before this listener existed yet. + const sub = PluginManager.registerButtonListener({ + onButtonPress: event => { + const next = event.id === 101 ? 'above' : 'below'; + log('onButtonPress id=', event.id, '-> direction=', next); + setDirection(next); + }, + }); + return () => { + log('App unmounted'); + sub.remove(); + }; }, []); const onLayout = (e: LayoutChangeEvent) => { @@ -132,8 +160,9 @@ function App(): React.JSX.Element { viewHeight.current, ctx.width, ctx.height, + direction, ); - log('lasso rect=', rect); + log('direction=', direction, 'lasso rect=', rect); // `lassoElements` ALREADY creates and SHOWS the native selection box // (verified on-device: `AreaSelectionView.setLassoDate` fires and the box // is visible, exactly like a hand-drawn lasso). Do NOT additionally call @@ -207,7 +236,13 @@ function App(): React.JSX.Element { - {failed ? t('error.noNote') : t('hint.tapToInsertSpace')} + {failed + ? t('error.noNote') + : t( + direction === 'below' + ? 'hint.tapToInsertSpaceBelow' + : 'hint.tapToInsertSpaceAbove', + )} @@ -223,7 +258,9 @@ function App(): React.JSX.Element { {}}> {t('intro.title')} - {t('intro.body')} + + {t(direction === 'below' ? 'intro.bodyBelow' : 'intro.bodyAbove')} + Insert blank writing space anywhere on a Supernote page — just tap a line and slide everything below it up or down. +[![CI](https://github.com/gorlix/sn_make_space/actions/workflows/ci.yml/badge.svg)](https://github.com/gorlix/sn_make_space/actions/workflows/ci.yml) +[![Latest release](https://img.shields.io/github/v/release/gorlix/sn_make_space)](https://github.com/gorlix/sn_make_space/releases/latest) +[![License: MIT](https://img.shields.io/github/license/gorlix/sn_make_space)](LICENSE) -Ever filled a page by hand and then needed **one more line** in the middle? On paper you're stuck. On a Supernote, **Make Space** gives you room: tap where you need space, and everything underneath slides down (or back up) so you can keep writing. +> Insert blank writing space anywhere on a Supernote page — just tap a line and slide everything above or below it up or down. + +Ever filled a page by hand and then needed **one more line** in the middle? On paper you're stuck. On a Supernote, **Make Space** gives you room: tap where you need space, and everything above or below it slides (or back) so you can keep writing. Inspired by OneNote's _“Insert extra writing space”_, built as a native Supernote plugin. @@ -13,6 +17,7 @@ Inspired by OneNote's _“Insert extra writing space”_, built as a native Supe ![Make Space demo](docs/media/demo.gif) > Prefer full quality? [Watch the MP4](https://github.com/gorlix/sn_make_space/raw/main/docs/media/demo.mp4). +> Shows the **below** flow — **above** works the same, mirrored. --- @@ -20,10 +25,10 @@ Inspired by OneNote's _“Insert extra writing space”_, built as a native Supe You're writing notes. Two lines are too close together and you need to squeeze something in between. Instead of erasing and rewriting: -1. Open **Make Space** from the toolbar. +1. Open **Make Space Below** (or **Make Space Above**) from the toolbar, depending on which side of your tap you want to select. 2. A light **grey frame** appears around the screen — that's your cue. 3. **Tap** the spot where you want room. -4. Everything below that point gets selected — now **drag it up or down**: down to open space, up to close a gap. +4. Everything on that side of the tap gets selected — now **drag it up or down**: open space, or close a gap. That's it. The move is the Supernote's own selection drag, so **undo works normally**. @@ -55,7 +60,7 @@ Then install it: ``` (or just copy it into the `MyStyle` folder over USB) 2. On the Supernote: **Settings → Apps → Plugins → Install** and pick `sn_make_space`. -3. Open a note, tap **Make Space** in the toolbar, and go. +3. Open a note, tap **Make Space Below** or **Make Space Above** in the toolbar, and go. > Works in the **NOTE** app. @@ -93,7 +98,7 @@ npm test # Jest | Path | What's inside | | ------------------ | ---------------------------------------------------- | -| `index.js` | Plugin entry — registers the toolbar button | +| `index.js` | Plugin entry — registers the two toolbar buttons | | `App.tsx` | The overlay: grey frame, tap handling, lasso + close | | `src/makeSpace.ts` | Pure tap-to-rectangle math (unit-tested) | | `src/sdk.ts` | Typed wrapper over `sn-plugin-lib` | @@ -104,7 +109,7 @@ npm test # Jest ## 🧭 How it works under the hood -The Supernote SDK has no “move selection” command, so Make Space leans on what the device already does well: it turns your tap into a **native lasso** of everything below the line, then hands control back so you drag it yourself. Simple, reliable, and undoable. +The Supernote SDK has no “move selection” command, so Make Space leans on what the device already does well: it turns your tap into a **native lasso** of everything above or below the line, then hands control back so you drag it yourself. Simple, reliable, and undoable. The full **one-gesture auto-move** (drag once, everything shifts automatically) is the next milestone — see the roadmap. diff --git a/__tests__/makeSpace.test.ts b/__tests__/makeSpace.test.ts index ac0df87..d24e82c 100644 --- a/__tests__/makeSpace.test.ts +++ b/__tests__/makeSpace.test.ts @@ -5,42 +5,105 @@ import {computeLassoRect} from '../src/makeSpace'; const PAGE_W = 1404; const PAGE_H = 1872; -describe('computeLassoRect', () => { +describe('computeLassoRect — below', () => { it('maps a tap at half the view to half the page height', () => { - const rect = computeLassoRect(PAGE_H / 2, PAGE_H, PAGE_W, PAGE_H); + const rect = computeLassoRect(PAGE_H / 2, PAGE_H, PAGE_W, PAGE_H, 'below'); expect(rect.top).toBe(Math.round(PAGE_H / 2)); }); it('selects the whole page when tapping at the very top', () => { - const rect = computeLassoRect(0, PAGE_H, PAGE_W, PAGE_H); + const rect = computeLassoRect(0, PAGE_H, PAGE_W, PAGE_H, 'below'); expect(rect).toEqual({left: 0, top: 0, right: PAGE_W, bottom: PAGE_H}); }); it('clamps a tap past the bottom to pageH', () => { - const rect = computeLassoRect(PAGE_H + 500, PAGE_H, PAGE_W, PAGE_H); + const rect = computeLassoRect( + PAGE_H + 500, + PAGE_H, + PAGE_W, + PAGE_H, + 'below', + ); expect(rect.top).toBe(PAGE_H); }); it('clamps a negative tap to 0', () => { - const rect = computeLassoRect(-50, PAGE_H, PAGE_W, PAGE_H); + const rect = computeLassoRect(-50, PAGE_H, PAGE_W, PAGE_H, 'below'); expect(rect.top).toBe(0); }); it('scales correctly when the view height differs from the page height', () => { // View is 800 DP tall, tap at 400 DP = halfway → half the page in pixels. - const rect = computeLassoRect(400, 800, PAGE_W, PAGE_H); + const rect = computeLassoRect(400, 800, PAGE_W, PAGE_H, 'below'); expect(rect.top).toBe(Math.round((400 / 800) * PAGE_H)); }); it('treats a not-yet-laid-out view (height 0) as a top tap', () => { - const rect = computeLassoRect(123, 0, PAGE_W, PAGE_H); + const rect = computeLassoRect(123, 0, PAGE_W, PAGE_H, 'below'); expect(rect.top).toBe(0); }); it('always spans full width and reaches the page bottom', () => { - const rect = computeLassoRect(PAGE_H * 0.3, PAGE_H, PAGE_W, PAGE_H); + const rect = computeLassoRect( + PAGE_H * 0.3, + PAGE_H, + PAGE_W, + PAGE_H, + 'below', + ); expect(rect.left).toBe(0); expect(rect.right).toBe(PAGE_W); expect(rect.bottom).toBe(PAGE_H); }); }); + +describe('computeLassoRect — above', () => { + it('maps a tap at half the view to half the page height', () => { + const rect = computeLassoRect(PAGE_H / 2, PAGE_H, PAGE_W, PAGE_H, 'above'); + expect(rect.bottom).toBe(Math.round(PAGE_H / 2)); + }); + + it('selects nothing when tapping at the very top', () => { + const rect = computeLassoRect(0, PAGE_H, PAGE_W, PAGE_H, 'above'); + expect(rect).toEqual({left: 0, top: 0, right: PAGE_W, bottom: 0}); + }); + + it('selects the whole page when tapping past the bottom (clamped)', () => { + const rect = computeLassoRect( + PAGE_H + 500, + PAGE_H, + PAGE_W, + PAGE_H, + 'above', + ); + expect(rect).toEqual({left: 0, top: 0, right: PAGE_W, bottom: PAGE_H}); + }); + + it('clamps a negative tap to selecting nothing (bottom 0)', () => { + const rect = computeLassoRect(-50, PAGE_H, PAGE_W, PAGE_H, 'above'); + expect(rect.bottom).toBe(0); + }); + + it('scales correctly when the view height differs from the page height', () => { + const rect = computeLassoRect(400, 800, PAGE_W, PAGE_H, 'above'); + expect(rect.bottom).toBe(Math.round((400 / 800) * PAGE_H)); + }); + + it('treats a not-yet-laid-out view (height 0) as a top tap (selects nothing)', () => { + const rect = computeLassoRect(123, 0, PAGE_W, PAGE_H, 'above'); + expect(rect.bottom).toBe(0); + }); + + it('always spans full width and starts at the page top', () => { + const rect = computeLassoRect( + PAGE_H * 0.3, + PAGE_H, + PAGE_W, + PAGE_H, + 'above', + ); + expect(rect.left).toBe(0); + expect(rect.right).toBe(PAGE_W); + expect(rect.top).toBe(0); + }); +}); diff --git a/app.json b/app.json index 4af4d78..2fbb3d4 100644 --- a/app.json +++ b/app.json @@ -1,4 +1,4 @@ { "name": "sn_make_space", - "displayName": "sn_make_space" + "displayName": "Make Space" } diff --git a/assets/icon-above.png b/assets/icon-above.png new file mode 100644 index 0000000..f683410 Binary files /dev/null and b/assets/icon-above.png differ diff --git a/assets/icon-below.png b/assets/icon-below.png new file mode 100644 index 0000000..f209237 Binary files /dev/null and b/assets/icon-below.png differ diff --git a/assets/icon.png b/assets/icon.png index ef0e471..4ef814e 100644 Binary files a/assets/icon.png and b/assets/icon.png differ diff --git a/index.js b/index.js index 5b0ecc0..0866601 100644 --- a/index.js +++ b/index.js @@ -35,13 +35,45 @@ AppRegistry.registerComponent(appName, () => App); PluginManager.init(); log('PluginManager.init() done'); -// Single toolbar/sidebar button (NOTE only). Tapping it opens the plugin UI -// (App.tsx) full-screen. `name` is a serialized JSON map so the label follows -// the device language. +// Two toolbar/sidebar buttons (NOTE only), one per cut direction. Tapping +// either opens the same plugin UI (App.tsx) full-screen; App.tsx tells them +// apart via the Pending Button ID pattern below. `name` is a serialized JSON +// map so the label follows the device language. PluginManager.registerButton(1, ['NOTE'], { id: 100, - name: JSON.stringify({en: 'Make Space', it: 'Fai Spazio'}), - icon: Image.resolveAssetSource(require('./assets/icon.png')).uri, + name: JSON.stringify({en: 'Make Space Below', it: 'Fai Spazio Sotto'}), + icon: Image.resolveAssetSource(require('./assets/icon-below.png')).uri, showType: 1, }); -log('button 100 registered'); +PluginManager.registerButton(1, ['NOTE'], { + id: 101, + name: JSON.stringify({en: 'Make Space Above', it: 'Fai Spazio Sopra'}), + icon: Image.resolveAssetSource(require('./assets/icon-above.png')).uri, + showType: 1, +}); +log('buttons 100/101 registered'); + +// Pending Button ID pattern (references/patterns.md Pattern 5, SKILL.md +// gotcha #11): on the very first open, this listener can fire before App.tsx +// has mounted and registered its own, so stash the direction at module level +// and let App.tsx consume it once on mount. For every later open, PluginHost +// reuses the same App instance (see make-space.md §4) — App.tsx's own +// listener (set up once, stays alive) handles those directly. +let pendingDirection = null; +PluginManager.registerButtonListener({ + onButtonPress(event) { + pendingDirection = event.id === 101 ? 'above' : 'below'; + log( + 'button pressed, id=', + event.id, + '-> pendingDirection=', + pendingDirection, + ); + }, +}); + +export const checkPendingDirection = () => { + const d = pendingDirection; + pendingDirection = null; + return d; +}; diff --git a/package.json b/package.json index 48cba44..e33052c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "sn_make_space", "version": "0.0.1", + "description": "Insert extra writing space anywhere on a Supernote NOTE page — tap a line, drag to open or close room. Native Supernote plugin.", "private": true, "scripts": { "android": "react-native run-android", diff --git a/src/i18n/locales/en_US.json b/src/i18n/locales/en_US.json index 9b7e48f..f686b8d 100644 --- a/src/i18n/locales/en_US.json +++ b/src/i18n/locales/en_US.json @@ -1,13 +1,15 @@ { "hint": { - "tapToInsertSpace": "Press and drag to set the line, lift to make space" + "tapToInsertSpaceBelow": "Press and drag to set the line, lift to make space below", + "tapToInsertSpaceAbove": "Press and drag to set the line, lift to make space above" }, "error": { "noNote": "No note open" }, "intro": { "title": "How Make Space works", - "body": "The grey frame means Make Space is active — you're selecting an area. Press and drag to place the cut line where you need room; lift the pen and everything below it is selected, then drag it up or down to add or close space.", + "bodyBelow": "The grey frame means Make Space is active — you're selecting an area. Press and drag to place the cut line where you need room; lift the pen and everything below it is selected, then drag it up or down to add or close space.", + "bodyAbove": "The grey frame means Make Space is active — you're selecting an area. Press and drag to place the cut line where you need room; lift the pen and everything above it is selected, then drag it up or down to add or close space.", "gotIt": "Got it", "dontShowAgain": "Don't show again" } diff --git a/src/i18n/locales/it_IT.json b/src/i18n/locales/it_IT.json index 9024a01..ef895f5 100644 --- a/src/i18n/locales/it_IT.json +++ b/src/i18n/locales/it_IT.json @@ -1,13 +1,15 @@ { "hint": { - "tapToInsertSpace": "Tieni premuto e trascina per posizionare la linea, alza per fare spazio" + "tapToInsertSpaceBelow": "Tieni premuto e trascina per posizionare la linea, alza per fare spazio sotto", + "tapToInsertSpaceAbove": "Tieni premuto e trascina per posizionare la linea, alza per fare spazio sopra" }, "error": { "noNote": "Nessuna nota aperta" }, "intro": { "title": "Come funziona Make Space", - "body": "Il bordo grigio indica che Make Space è attivo: stai selezionando un'area. Tieni premuto e trascina per posizionare la linea dove ti serve spazio; alza la penna e tutto ciò che sta sotto viene selezionato, poi trascinalo su o giù per aggiungere o chiudere spazio.", + "bodyBelow": "Il bordo grigio indica che Make Space è attivo: stai selezionando un'area. Tieni premuto e trascina per posizionare la linea dove ti serve spazio; alza la penna e tutto ciò che sta sotto viene selezionato, poi trascinalo su o giù per aggiungere o chiudere spazio.", + "bodyAbove": "Il bordo grigio indica che Make Space è attivo: stai selezionando un'area. Tieni premuto e trascina per posizionare la linea dove ti serve spazio; alza la penna e tutto ciò che sta sopra viene selezionato, poi trascinalo su o giù per aggiungere o chiudere spazio.", "gotIt": "Ho capito", "dontShowAgain": "Non mostrare più" } diff --git a/src/makeSpace.ts b/src/makeSpace.ts index 2e6ff6f..e148708 100644 --- a/src/makeSpace.ts +++ b/src/makeSpace.ts @@ -9,37 +9,46 @@ /** Axis-aligned rectangle in page pixel coordinates (top-left origin). */ export type Rect = {left: number; top: number; right: number; bottom: number}; +/** Which side of the tapped line gets selected. */ +export type CutDirection = 'above' | 'below'; + /** - * Build the lasso rectangle covering everything BELOW the tapped line. + * Build the lasso rectangle covering everything ABOVE or BELOW the tapped line. * * The user taps a Y position inside the fullscreen plugin overlay. That Y is in * DP, relative to the view, so we map it to page **pixels** by proportion * (`tapY / viewH`) rather than assuming a 1:1 DP↔pixel ratio — this is robust to * screen density and to the view not matching the page resolution exactly. * - * The resulting rect spans the full page width and runs from the cut line down - * to the page bottom, which is what `PluginCommAPI.lassoElements(rect)` expects - * (pixel coordinates). `top` is clamped to `[0, pageH]` so a tap outside the - * page (e.g. on the border frame) can never produce an inverted/invalid rect. + * The resulting rect always spans the full page width; `direction` picks which + * side of the clamped cut line it covers (top-to-cut for 'above', cut-to-bottom + * for 'below') — this is what `PluginCommAPI.lassoElements(rect)` expects + * (pixel coordinates). The cut position is clamped to `[0, pageH]` so a tap + * outside the page (e.g. on the border frame) can never produce an + * inverted/invalid rect. * * Note: `lassoElements` only selects strokes whose contour is *fully* inside the * rect, so a stroke crossing the cut line is intentionally left unselected. * - * @param tapY tap Y in DP (PressEvent.nativeEvent.locationY) - * @param viewH measured height of the plugin view in DP (from onLayout) - * @param pageW page width in pixels (PluginFileAPI.getPageSize) - * @param pageH page height in pixels - * @returns lasso rect in page pixels: {left: 0, top, right: pageW, bottom: pageH} + * @param tapY tap Y in DP (PressEvent.nativeEvent.locationY) + * @param viewH measured height of the plugin view in DP (from onLayout) + * @param pageW page width in pixels (PluginCommAPI.getPageDisplaySize) + * @param pageH page height in pixels + * @param direction which side of the cut line to select + * @returns lasso rect in page pixels, full width, on the requested side of the cut */ export function computeLassoRect( tapY: number, viewH: number, pageW: number, pageH: number, + direction: CutDirection, ): Rect { // Guard against a zero/negative view height (not laid out yet): treat as top. const ratio = viewH > 0 ? tapY / viewH : 0; - const rawTop = Math.round(ratio * pageH); - const top = Math.max(0, Math.min(rawTop, pageH)); - return {left: 0, top, right: pageW, bottom: pageH}; + const rawCut = Math.round(ratio * pageH); + const cutY = Math.max(0, Math.min(rawCut, pageH)); + return direction === 'below' + ? {left: 0, top: cutY, right: pageW, bottom: pageH} + : {left: 0, top: 0, right: pageW, bottom: cutY}; }