Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 6 additions & 1 deletion .claude/skills/supernote-plugin-dev/references/make-space.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
51 changes: 44 additions & 7 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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';

Expand Down Expand Up @@ -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<CutDirection>(
() => 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.
Expand All @@ -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) => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -207,7 +236,13 @@ function App(): React.JSX.Element {
<View style={styles.hintBar} pointerEvents="none">
<View style={styles.hintPill}>
<Text style={styles.hintText}>
{failed ? t('error.noNote') : t('hint.tapToInsertSpace')}
{failed
? t('error.noNote')
: t(
direction === 'below'
? 'hint.tapToInsertSpaceBelow'
: 'hint.tapToInsertSpaceAbove',
)}
</Text>
</View>
</View>
Expand All @@ -223,7 +258,9 @@ function App(): React.JSX.Element {
<Pressable style={styles.introBackdrop} onPress={() => {}}>
<View style={styles.introCard}>
<Text style={styles.introTitle}>{t('intro.title')}</Text>
<Text style={styles.introBody}>{t('intro.body')}</Text>
<Text style={styles.introBody}>
{t(direction === 'below' ? 'intro.bodyBelow' : 'intro.bodyAbove')}
</Text>
<View style={styles.introButtons}>
<Pressable
style={styles.introBtnGhost}
Expand Down
4 changes: 2 additions & 2 deletions PluginConfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sn_make_space",
"desc": "Insert extra writing space on a NOTE page — press and drag a cut line, then drag everything below it up or down to make room.",
"name": "Make Space",
"desc": "Insert extra writing space anywhere on a NOTE page — tap a line, then drag everything above or below it to open or close room. OneNote-style, fully undoable.",
"iconPath": "assets/icon.png",
"versionName": "0.5.0",
"versionCode": "1",
Expand Down
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
# ✍️ Make Space

> 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.

Expand All @@ -13,17 +17,18 @@ 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.

---

## ✨ What it does

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**.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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` |
Expand All @@ -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.

Expand Down
79 changes: 71 additions & 8 deletions __tests__/makeSpace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
2 changes: 1 addition & 1 deletion app.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"name": "sn_make_space",
"displayName": "sn_make_space"
"displayName": "Make Space"
}
Binary file added assets/icon-above.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/icon-below.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
44 changes: 38 additions & 6 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 4 additions & 2 deletions src/i18n/locales/en_US.json
Original file line number Diff line number Diff line change
@@ -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"
}
Expand Down
Loading
Loading