From 05b60e3c14dc17569daba1aa9d6c286337579d80 Mon Sep 17 00:00:00 2001 From: Alessandro Gorla Date: Tue, 1 Sep 2026 17:42:42 +0200 Subject: [PATCH] feat(lasso): add "Make Space Above" + presentability pass for InkHub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "above" cut direction, chosen via a second toolbar button (id 101), alongside the existing "below" (id 100, unchanged behavior). Both open the same overlay; App.tsx tells them apart via the Pending Button ID pattern (references/patterns.md Pattern 5) since PluginHost reuses the App instance across opens rather than remounting it. - src/makeSpace.ts: computeLassoRect takes a required CutDirection ('above' | 'below'); same clamped cut-line math either way, only which side of it becomes the rect changes. Mirror test coverage added. - index.js: registers button 101, module-level pendingDirection + registerButtonListener for the pre-mount race, exports checkPendingDirection(). - App.tsx: direction state seeded from checkPendingDirection() on mount, kept live afterwards via its own registerButtonListener (stays subscribed for the reused instance's whole lifetime). Hint/intro copy now direction-aware. - Locale files: tapToInsertSpaceBelow/Above, bodyBelow/Above. Presentability pass, ahead of eventual InkHub listing (see make-space.md §11 — no submission spec exists yet, this is the no-regret prep bucket): - Three new hand-drawn icons (assets/icon.png redesigned, +icon-below.png, +icon-above.png) replacing the generic unused-template puzzle piece. - PluginConfig.json `name`: "sn_make_space" -> "Make Space" (this is the human-facing title shown in Settings -> Apps -> Plugins; independent of `pluginKey`/`pluginID`, which stay untouched — changing those would confuse the host's plugin registry for anyone with an existing install). `desc` polished into a proper listing-quality description. - app.json `displayName` and package.json `description` filled in to match (app.json `name` left alone — it's the actual AppRegistry key, coupled to PluginConfig's `pluginKey`). - README: badges, fixed stale "below"-only wording now that both directions exist. Co-Authored-By: Claude Sonnet 5 --- .../references/make-space.md | 7 +- App.tsx | 51 +++++++++-- PluginConfig.json | 4 +- README.md | 19 +++-- __tests__/makeSpace.test.ts | 79 ++++++++++++++++-- app.json | 2 +- assets/icon-above.png | Bin 0 -> 445 bytes assets/icon-below.png | Bin 0 -> 426 bytes assets/icon.png | Bin 481 -> 488 bytes index.js | 44 ++++++++-- package.json | 1 + src/i18n/locales/en_US.json | 6 +- src/i18n/locales/it_IT.json | 6 +- src/makeSpace.ts | 35 +++++--- 14 files changed, 205 insertions(+), 49 deletions(-) create mode 100644 assets/icon-above.png create mode 100644 assets/icon-below.png 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 0000000000000000000000000000000000000000..f683410aece35d422415d522f83e1afb886b6b12 GIT binary patch literal 445 zcmV;u0Yd(XP)x?Zcff04*9~-BaWHqRqxd zdt`&aW`P%x(7EUB!zj nlRuQuB4OReyI2%OQG~}2qQZ|ywJeF~00000NkvXXu0mjf7J|!Q literal 0 HcmV?d00001 diff --git a/assets/icon-below.png b/assets/icon-below.png new file mode 100644 index 0000000000000000000000000000000000000000..f20923718c180be955f989bc2118bcf4f774a38e GIT binary patch literal 426 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTCwj^(N7l!{JxM1({$qWpP@t!V@ zAsLNtZ|wCuY#?ypV?96H4XrQ9(N*Lrlr^Rvs$$J+n7@nH_`XJ) z^6FPh44757ct-e7o)#dqt7X%T!1A*zZPuDe z9*`?|`q=crnzY}_4?=jKS*399JohE5X8-fsEKk|$m(Tkd-=I3xKG0J9`K|+-Pu*oM z_O&uxnV%qE@%g#-fu|C0^%_Fu_y3+gSD)$Dx8{@UUmWbPE{M4C{-&bP^Hss~4}_g$ zFl*rddb6QJhoOH}ai0G2_2+)8{&ZCe-gAgww6LyyQOKH)e>8sgySy$tqPF(5>-y*! z&sSgTT(NU%*$ONBH9LdcA4c#VIJ)-5mam8ZC+3@Y3f=n7tzXrzQDMa92Ml}$Pgg&e IbxsLQ0OD}FYXATM literal 0 HcmV?d00001 diff --git a/assets/icon.png b/assets/icon.png index ef0e47186a2c67717121be685f70704155994b65..4ef814e03a3557be30feeeaebb1378f1d8ec090a 100644 GIT binary patch delta 462 zcmV;<0WtpJ1Ly;gB!32COGiWi{{a60|De66lK=n#ok>JNRA_`+mHa>u;sca70)878hQoPzfg^iW(U?p6krC_0jSm@cRoGk3Y5OSWHO|ld7 z!E&?7FqzFwlHK1zP0jx%$~pF?v(JD5%ILVHZEK^9ZIS%2?SG(zRp32k03W~_fwKto zQ<5LHzViu^Y2aQY`zGvHVLt$~1jZzAl}P?b^bdg-(Z2?!d|cRgieX=qE3W?uYyeNf z=NVXc&w*tbI0q&OfH+a-O?P}>9J4m=ob-)D_y;5hK=+&TUFYMChKCrZ;wx)QX&ie% z?-%~L4`AR1k$;zep(Ru}2IUx(V^EGkIVQ*0cO&e=R%DS|QO#tCz2xj(h85Z4UsrU= zDMFbouIw`q{h)*vGQ^*Ezqc7;+>|8>#6D-8Wt7m|2-_P>w-42IVwL5OKYV zz74Xo1spru&V6>r_*6TdjJ3c delta 455 zcmV;&0XY8X1K|UZB!3BTNLh0L01FcU01FcV0GgZ_00001b5ch_0Itp)=>Px#1ZP1_ zK>z@;j|==^1poj6c1c7*RCodHnZa?xAP_~rlN6v5R6zZk2n)ns*?wYS#IaAw?O}0s0W};4?vsLTc~!*%;x5n x&^OxQlhWwXJN~kIhW-y}H<53sUvqTsj~{&;O%n<@oB03$002ovPDHLkV1iOO)Mx+z 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}; }